feat: implement 33 nice-to-have features + fix 37 code review bugs
5 SDD batches archived: - Batch 1: UI Polish (10 features, 14 tasks) - Batch 2: Study System (8 features, 23 tasks) - Batch 3: Infrastructure (5 features, 22 tasks) - Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers - Batch 5: Core Features (5 features, 19 tasks) 37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW): - SSE streaming now works (event.token check) - API keys no longer exposed via GET /api/models - FTS5 injection sanitized - DB backup/restore with admin auth - Buddy mode wired (buddy_meta column) - Exam auto-submit stale closure fixed - CSS variables aligned with design tokens - Progress data corruption fixed - WebSocket protocol auto-detection - Tests infrastructure completed (vitest + node:test)
This commit is contained in:
@@ -1,41 +1,93 @@
|
||||
# Skill Registry — studyos
|
||||
# Skill Registry
|
||||
|
||||
> Auto-generated by sdd-init on 2026-06-07
|
||||
**Delegator use only.** Any agent that launches sub-agents reads this registry to resolve compact rules, then injects them directly into sub-agent prompts. Sub-agents do NOT read this registry or individual SKILL.md files.
|
||||
|
||||
> Auto-generated by sdd-init on 2026-06-08
|
||||
> Project: studyos (Node.js + Express + SQLite + React + Vite)
|
||||
|
||||
## User Skills
|
||||
|
||||
| Skill | Trigger | Compact Rules |
|
||||
|-------|---------|---------------|
|
||||
| frontend-design | React components, HTML/CSS layouts, styling/beautifying web UI | Create production-grade frontend with high design quality. Choose a bold aesthetic direction. Implement real working code with typography, color, layout, spacing, and motion. Avoid generic AI aesthetics. |
|
||||
| claude-api | Code imports `anthropic`/`@anthropic-ai/sdk`, Anthropic SDK, Claude API | Use official Anthropic SDK. Default to claude-opus-4-7. Use adaptive thinking. Stream long requests. Never mix OpenAI-compatible shims with Anthropic SDK. |
|
||||
| webapp-testing | Testing local web apps, verifying frontend functionality, Playwright | Use native Python Playwright scripts. Run `scripts/with_server.py --help` for server lifecycle. Reconnaissance-then-action pattern. |
|
||||
| Trigger | Skill | Path |
|
||||
|---------|-------|------|
|
||||
| React components, HTML/CSS layouts, styling/beautifying web UI | frontend-design | ~/.config/opencode/skills/frontend-design/SKILL.md |
|
||||
| Code imports `anthropic`/`@anthropic-ai/sdk`, Anthropic SDK, Claude API | claude-api | ~/.config/opencode/skills/claude-api/SKILL.md |
|
||||
| Testing local web apps, verifying frontend functionality, Playwright | webapp-testing | ~/.config/opencode/skills/webapp-testing/SKILL.md |
|
||||
| Creating algorithmic art, generative art, flow fields, particle systems | algorithmic-art | ~/.config/opencode/skills/algorithmic-art/SKILL.md |
|
||||
| Anthropic brand colors and typography, company design standards | brand-guidelines | ~/.config/opencode/skills/brand-guidelines/SKILL.md |
|
||||
| Create poster, piece of art, design, static visual piece | canvas-design | ~/.config/opencode/skills/canvas-design/SKILL.md |
|
||||
| Writing docs, proposals, technical specs, decision docs | doc-coauthoring | ~/.config/opencode/skills/doc-coauthoring/SKILL.md |
|
||||
| Word doc, .docx, create/read/edit Word documents | docx | ~/.config/opencode/skills/docx/SKILL.md |
|
||||
| Download from e-hentai.org | e-hentai-downloader | ~/.config/opencode/skills/e-hentai-downloader/SKILL.md |
|
||||
| FL Studio music production, create music, compose, beats | fl-control | ~/.config/opencode/skills/fl-control/SKILL.md |
|
||||
| Go tests, Bubbletea TUI testing, teatest | go-testing | ~/.config/opencode/skills/go-testing/SKILL.md |
|
||||
| Internal communications, status reports, newsletters, FAQs | internal-comms | ~/.config/opencode/skills/internal-comms/SKILL.md |
|
||||
| PR creation workflow, opening a PR, preparing changes for review | branch-pr | ~/.config/opencode/skills/branch-pr/SKILL.md |
|
||||
| GitHub issue creation, reporting a bug, requesting a feature | issue-creation | ~/.config/opencode/skills/issue-creation/SKILL.md |
|
||||
| Parallel adversarial review, "judgment day", dual review | judgment-day | ~/.claude/skills/judgment-day/SKILL.md |
|
||||
| Convert manga CBZ/CBR to Kindle MOBI using KCC | kcc-manga | ~/.config/opencode/skills/kcc-manga/SKILL.md |
|
||||
| Building MCP servers, integrate external APIs via MCP | mcp-builder | ~/.config/opencode/skills/mcp-builder/SKILL.md |
|
||||
| Download from nhentai.net | nhentai-downloader | ~/.config/opencode/skills/nhentai-downloader/SKILL.md |
|
||||
| PDF files: read, merge, split, rotate, watermark, create, OCR | pdf | ~/.config/opencode/skills/pdf/SKILL.md |
|
||||
| .pptx slides, presentations, pitch decks | pptx | ~/.config/opencode/skills/pptx/SKILL.md |
|
||||
| Build reggaeton songs in Ableton Live 12 via TCP | reggaeton-builder | ~/.config/opencode/skills/reggaeton-builder/SKILL.md |
|
||||
| Full reggaeton production in Ableton Live 12, mixing, mastering | reggaeton-production-ableton | ~/.config/opencode/skills/reggaeton-production-ableton/SKILL.md |
|
||||
| Create new AI agent skills, add agent instructions | skill-creator | ~/.config/opencode/skills/skill-creator/SKILL.md |
|
||||
| Animated GIFs for Slack | slack-gif-creator | ~/.config/opencode/skills/slack-gif-creator/SKILL.md |
|
||||
| Styling artifacts with a theme (slides, docs, reports, HTML) | theme-factory | ~/.config/opencode/skills/theme-factory/SKILL.md |
|
||||
| Multi-component claude.ai HTML artifacts, React + Tailwind + shadcn | web-artifacts-builder | ~/.config/opencode/skills/web-artifacts-builder/SKILL.md |
|
||||
| Spreadsheet: open, read, edit, create .xlsx/.csv/.tsv files | xlsx | ~/.config/opencode/skills/xlsx/SKILL.md |
|
||||
|
||||
## Compact Rules
|
||||
|
||||
### frontend-design
|
||||
- Use distinctive, characterful fonts (avoid Arial, Inter). Pair display + body fonts.
|
||||
- Bold aesthetic direction: brutalist, editorial, organic, luxury, retro-futuristic, etc.
|
||||
- Production-grade code: real components, real interactions, responsive.
|
||||
- Meticulous details: border-radius, shadows, spacing, hover states, transitions.
|
||||
- Never use placeholder lorem ipsum — use real content.
|
||||
- Choose a BOLD aesthetic direction before coding (brutalist, editorial, organic, luxury, retro-futuristic, etc.)
|
||||
- Use distinctive, characterful fonts — NEVER Arial, Inter, Roboto, or system fonts
|
||||
- Pair a display font with a refined body font
|
||||
- Production-grade code: real components, real interactions, responsive
|
||||
- Meticulous details: border-radius, shadows, spacing, hover states, transitions
|
||||
- Never use placeholder lorem ipsum — use real content
|
||||
- NEVER converge on common choices (Space Grotesk, purple gradients on white)
|
||||
- CSS variables for theme consistency; dominant colors with sharp accents
|
||||
|
||||
### claude-api
|
||||
- Anthropic SDK: `import Anthropic from '@anthropic-ai/sdk'` (Node) or `from anthropic import Anthropic` (Python).
|
||||
- Always use `anthropic.messages.create()` with `model`, `max_tokens`, `messages`, `system`.
|
||||
- Streaming: set `stream: true`, iterate `stream` events.
|
||||
- Prompt caching: add `cache_control: { type: "ephemeral" }` to system and last turn.
|
||||
- Never use OpenAI-compatible endpoints for Anthropic models.
|
||||
- Use official Anthropic SDK: `import Anthropic from '@anthropic-ai/sdk'` (Node)
|
||||
- Default model: `claude-opus-4-7` via exact model string
|
||||
- Use `anthropic.messages.create()` with `model`, `max_tokens`, `messages`, `system`
|
||||
- Streaming: set `stream: true`, iterate stream events; use `.finalMessage()` for complete response
|
||||
- Prompt caching: add `cache_control: { type: "ephemeral" }` to system and last turn
|
||||
- Adaptive thinking: `thinking: {type: "adaptive"}` for complex tasks
|
||||
- Never use OpenAI-compatible endpoints for Anthropic models
|
||||
- Never mix `requests`/`fetch` with SDK in same project
|
||||
|
||||
### webapp-testing
|
||||
- Playwright scripts in Python. Install: `pip install playwright`.
|
||||
- Use `with_server.py` helper for server lifecycle.
|
||||
- Screenshot on failure for debugging.
|
||||
- Wait for networkidle after navigation.
|
||||
- Write native Python Playwright scripts
|
||||
- Use `scripts/with_server.py --help` for server lifecycle management
|
||||
- Reconnaissance-then-action: navigate → wait for networkidle → screenshot → identify selectors → act
|
||||
- Screenshot on failure for debugging
|
||||
- Supports multiple servers (backend + frontend) via repeated `--server` flags
|
||||
|
||||
### algorithmic-art
|
||||
- Use p5.js with seeded randomness for reproducible generative art
|
||||
- Create original art, never copy existing artists' work
|
||||
|
||||
### canvas-design
|
||||
- Create original visual designs in .png/.pdf, never copy existing artists' work
|
||||
|
||||
### doc-coauthoring
|
||||
- Structured workflow: transfer context → iterate → verify with reader
|
||||
|
||||
### mcp-builder
|
||||
- Guide for creating MCP servers in Python (FastMCP) or Node/TypeScript (MCP SDK)
|
||||
|
||||
### pdf
|
||||
- Read, merge, split, rotate, watermark, create, encrypt, OCR PDF files
|
||||
|
||||
### theme-factory
|
||||
- 10 pre-set themes with colors/fonts; apply to slides, docs, reports, HTML
|
||||
|
||||
## Project Conventions
|
||||
|
||||
No project conventions detected yet. Create AGENTS.md or CLAUDE.md in the project root to establish conventions.
|
||||
No project conventions detected. Create `AGENTS.md` or `CLAUDE.md` in the project root to establish conventions.
|
||||
|
||||
## Testing Capabilities
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ data/*.db
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
.atl/
|
||||
openspec/
|
||||
|
||||
@@ -5,10 +5,29 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StudyOS</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📚</text></svg>" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="apple-touch-icon" href="/pwa-icons/icon-192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" integrity="sha384-nB0miv6/jRmo5UMMR1wu3Gz7NLsoWkbqJmINEFemQVi4AvPgz4t1qAQ4N6BbKEX4" crossorigin="anonymous" />
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
const stored = localStorage.getItem('studyos-theme');
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
document.documentElement.dataset.theme = stored;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
} else {
|
||||
document.documentElement.dataset.theme = 'dark';
|
||||
}
|
||||
} catch (e) {
|
||||
document.documentElement.dataset.theme = 'dark';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body style="background-color:var(--bg-base);">
|
||||
<div id="root"></div>
|
||||
|
||||
5484
client/package-lock.json
generated
5484
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
@@ -13,6 +15,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"katex": "^0.17.0",
|
||||
"lucide-react": "^0.440.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
@@ -20,9 +23,14 @@
|
||||
"recharts": "^2.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.0"
|
||||
"jsdom": "^25.0.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^0.20.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
client/public/pwa-icons/icon-192.png
Normal file
BIN
client/public/pwa-icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 B |
BIN
client/public/pwa-icons/icon-512.png
Normal file
BIN
client/public/pwa-icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 842 B |
@@ -65,7 +65,7 @@ html { background: var(--bg-base); }
|
||||
|
||||
/* Fork panel */
|
||||
.fork-panel { width: 0; overflow: hidden; transition: all var(--transition-slow); background: linear-gradient(180deg, rgba(22,24,34,0.98), rgba(15,17,23,0.97)); border-left: 1px solid var(--border); flex-shrink: 0; backdrop-filter: blur(24px); }
|
||||
.fork-panel.open { width: 320px; box-shadow: -12px 0 40px rgba(0,0,0,0.5); }
|
||||
.fork-panel.open { display: flex; box-shadow: -12px 0 40px rgba(0,0,0,0.5); }
|
||||
.fork-header { display: flex; align-items: center; padding: 14px; border-bottom: 1px solid var(--border); font-size: 13px; gap: 10px; background: linear-gradient(90deg, rgba(129,140,248,0.06), transparent); }
|
||||
.fork-chat { flex: 1; overflow-y: auto; padding: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
@@ -195,6 +195,7 @@ html { background: var(--bg-base); }
|
||||
.fork-resize-handle {
|
||||
width: 4px; cursor: col-resize; background: transparent;
|
||||
transition: background var(--transition-fast); position: relative; z-index: 10;
|
||||
flex-shrink: 0; align-self: stretch;
|
||||
}
|
||||
.fork-resize-handle:hover, .fork-resize-handle:active {
|
||||
background: var(--accent-info); box-shadow: 0 0 12px rgba(129,140,248,0.4);
|
||||
@@ -205,6 +206,77 @@ html { background: var(--bg-base); }
|
||||
.sidebar-pdf-item:hover { transform: translateX(2px); }
|
||||
.sidebar-note-item:hover { transform: translateX(2px); }
|
||||
|
||||
/* Copy button */
|
||||
.message-bubble:hover .copy-btn { opacity: 1; }
|
||||
.copy-btn {
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); color: var(--text-tertiary);
|
||||
font-size: 11px; padding: 4px 8px; cursor: pointer;
|
||||
opacity: 0; transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
z-index: 5;
|
||||
}
|
||||
.copy-btn:hover { color: var(--text-primary); background: var(--bg-elevated); }
|
||||
|
||||
/* Reaction pills */
|
||||
.reaction-pill {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: 4px; padding: 3px 10px; border-radius: var(--radius-pill);
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
color: var(--text-tertiary); font-size: 13px; cursor: pointer;
|
||||
transition: all var(--transition-fast); user-select: none;
|
||||
}
|
||||
.reaction-pill:hover { background: var(--bg-elevated); color: var(--text-primary); border-color: var(--border-glow); }
|
||||
.reaction-pill--active { background: rgba(129,140,248,0.15); border-color: rgba(129,140,248,0.4); color: var(--accent-info); }
|
||||
.reaction-pill--active:hover { background: rgba(129,140,248,0.22); }
|
||||
|
||||
/* Inline rename input */
|
||||
.input-rename {
|
||||
background: var(--bg-elevated); border: 1.5px solid var(--accent-info);
|
||||
border-radius: var(--radius-sm); color: var(--text-primary);
|
||||
font-family: var(--font-ui); font-size: 14px; font-weight: 600;
|
||||
padding: 4px 8px; outline: none; width: 100%; max-width: 400px;
|
||||
}
|
||||
.input-rename:focus { box-shadow: 0 0 0 3px rgba(129,140,248,0.15); }
|
||||
|
||||
/* Scroll FAB within MainChat */
|
||||
.scroll-fab {
|
||||
position: absolute; bottom: 20px; right: 20px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 14px; border-radius: var(--radius-pill);
|
||||
background: var(--accent-info); color: #fff;
|
||||
border: none; font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; z-index: 40;
|
||||
box-shadow: var(--shadow-md);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.scroll-fab.scroll-fab--visible { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* Sidebar backdrop */
|
||||
.sidebar-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(2px); z-index: 25;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
/* Hamburger button */
|
||||
.hamburger-btn {
|
||||
display: none; align-items: center; justify-content: center;
|
||||
background: none; border: none; color: var(--text-secondary);
|
||||
cursor: pointer; padding: 8px; border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.hamburger-btn:hover { color: var(--text-primary); background: var(--bg-elevated); }
|
||||
|
||||
/* Mobile sidebar open state override */
|
||||
@media (max-width: 767px) {
|
||||
.app-sidebar.sidebar--open { transform: translateX(0); }
|
||||
.hamburger-btn { display: flex; }
|
||||
}
|
||||
|
||||
|
||||
/* Modal improved */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 100; animation: fadeIn 0.2s ease; }
|
||||
.modal-content { background: linear-gradient(145deg, rgba(30,32,48,0.99), rgba(22,24,34,0.99)); border: 1px solid var(--border-glow); border-radius: var(--radius-lg); padding: 28px; min-width: 440px; max-width: 90vw; max-height: 85vh; overflow: auto; box-shadow: 0 0 60px rgba(129,140,248,0.1); animation: scaleIn 0.2s ease; }
|
||||
@@ -246,3 +318,54 @@ html { background: var(--bg-base); }
|
||||
margin: 0 4px;
|
||||
}
|
||||
.fork-inline-badge:hover { box-shadow: 0 0 12px rgba(251,191,36,0.2); border-color: var(--accent-amber); }
|
||||
|
||||
/* Heatmap grid */
|
||||
.heatmap-grid { display: flex; gap: 2px; }
|
||||
.heatmap-week { display: flex; flex-direction: column; gap: 2px; }
|
||||
.heatmap-cell { width: 12px; height: 12px; border-radius: 2px; }
|
||||
|
||||
/* Timer dial */
|
||||
.timer-dial { width: 220px; height: 220px; border-radius: 50%; position: relative; display: flex; align-items: center; justify-content: center; }
|
||||
.timer-dial-inner { width: 190px; height: 190px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
|
||||
/* Flashcard flip */
|
||||
.flashcard-container { perspective: 1000px; min-height: 220px; }
|
||||
.flashcard-inner { position: relative; width: 100%; min-height: 200px; transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); transform-style: preserve-3d; }
|
||||
.flashcard-front, .flashcard-back { position: absolute; inset: 0; backface-visibility: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 24px; }
|
||||
.flashcard-back { transform: rotateY(180deg); }
|
||||
|
||||
/* Roadmap SVG */
|
||||
.roadmap-node { cursor: pointer; transition: all var(--transition-fast); }
|
||||
.roadmap-node:hover { filter: brightness(1.2); }
|
||||
.roadmap-edge { stroke: var(--border-glow); stroke-width: 1.5; opacity: 0.6; }
|
||||
|
||||
/* Search dropdown */
|
||||
.search-dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md); box-shadow: var(--shadow-lg);
|
||||
z-index: 20; max-height: 320px; overflow-y: auto;
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
.search-group-header {
|
||||
padding: 6px 12px; font-size: 10px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: var(--accent-info); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search-result-item {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
width: 100%; padding: 8px 12px; border: none;
|
||||
background: transparent; color: var(--text-secondary);
|
||||
font-size: 12px; text-align: left; cursor: pointer;
|
||||
border-bottom: 1px solid var(--border); font-family: var(--font-ui);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.search-result-item:hover { background: var(--bg-glass); }
|
||||
|
||||
/* PDF compare panels */
|
||||
.pdf-compare-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
|
||||
.pdf-compare-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--bg-surface); }
|
||||
|
||||
/* LaTeX preview */
|
||||
.latex-preview { background: var(--bg-elevated); border-radius: var(--radius-md); padding: 12px 16px; min-height: 60px; font-size: 14px; overflow-x: auto; }
|
||||
.latex-preview--error { border-color: var(--accent-coral); }
|
||||
|
||||
@@ -1,24 +1,73 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Routes, Route, useNavigate, useLocation, useParams } from 'react-router-dom';
|
||||
import { Menu } from 'lucide-react';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import MainChat from './components/MainChat';
|
||||
import ChatInput from './components/ChatInput';
|
||||
import MessageBubble from './components/MessageBubble';
|
||||
import ForkPanel from './components/ForkPanel';
|
||||
import Settings from './pages/Settings';
|
||||
import ExamHistory from './components/ExamHistory';
|
||||
import FlashcardReview from './components/FlashcardReview';
|
||||
import CalendarHeatmap from './components/CalendarHeatmap';
|
||||
import RoadmapVisual from './components/RoadmapVisual';
|
||||
import PomodoroTimer from './components/PomodoroTimer';
|
||||
import MultiPdfCompare from './components/MultiPdfCompare';
|
||||
import AutoForkPrompt from './components/AutoForkPrompt';
|
||||
import ExamPanel from './components/ExamPanel';
|
||||
import StudyBuddyPanel from './components/StudyBuddyPanel';
|
||||
import { ToastProvider, useToast } from './components/Toast';
|
||||
import { ReactionsProvider } from './context/ReactionsContext';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import useChat from './hooks/useChat';
|
||||
import usePdfs from './hooks/usePdfs';
|
||||
import useProgress from './hooks/useProgress';
|
||||
import useExams from './hooks/useExams';
|
||||
import useFlashcards from './hooks/useFlashcards';
|
||||
import useStudySessions from './hooks/useStudySessions';
|
||||
import useExam from './hooks/useExam';
|
||||
import useStudyBuddy from './hooks/useStudyBuddy';
|
||||
import {
|
||||
getConversations,
|
||||
createConversation,
|
||||
updateConversation,
|
||||
getModels,
|
||||
forkConversation,
|
||||
mergeConversation,
|
||||
getNotes,
|
||||
shareConversation,
|
||||
getSharedConversation,
|
||||
generateExam,
|
||||
} from './lib/api';
|
||||
import './App.css';
|
||||
|
||||
function ToastBridge({ onReady }) {
|
||||
const { error } = useToast();
|
||||
useEffect(() => { onReady(error); }, [onReady, error]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function BuddyRouteWrapper({ onJoin, buddyHook, shareToken, onShare }) {
|
||||
const { token } = useParams();
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
onJoin(token);
|
||||
}
|
||||
}, [token, onJoin]);
|
||||
return (
|
||||
<StudyBuddyPanel
|
||||
conversation={buddyHook.conversation}
|
||||
messages={buddyHook.messages}
|
||||
roleLabel={buddyHook.roleLabel}
|
||||
counterpartLabel={buddyHook.counterpartLabel}
|
||||
onShare={onShare}
|
||||
shareToken={shareToken}
|
||||
onJoin={onJoin}
|
||||
loading={buddyHook.loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [conversaciones, setConversaciones] = useState([]);
|
||||
const [conversationActiva, setConversationActiva] = useState(null);
|
||||
@@ -27,16 +76,43 @@ export default function App() {
|
||||
const [modeloSeleccionado, setModeloSeleccionado] = useState(null);
|
||||
const [notas, setNotas] = useState([]);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [forkWidth, setForkWidth] = useState(320);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [hasLoadedConversations, setHasLoadedConversations] = useState(false);
|
||||
const [hasLoadedPdfs, setHasLoadedPdfs] = useState(false);
|
||||
const [pendingPrompt, setPendingPrompt] = useState('');
|
||||
const toastError = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
const handleToastReady = useCallback((fn) => { toastError.current = fn; }, []);
|
||||
|
||||
const [activeExam, setActiveExam] = useState(null);
|
||||
const [buddyToken, setBuddyToken] = useState(null);
|
||||
const [shareToken, setShareToken] = useState(null);
|
||||
|
||||
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const pdfsHook = usePdfs();
|
||||
const progressHook = useProgress();
|
||||
const examsHook = useExams();
|
||||
const flashcardsHook = useFlashcards();
|
||||
const sessionsHook = useStudySessions();
|
||||
const chatHook = useChat({
|
||||
conversationId: conversationActiva?.id ?? null,
|
||||
onProgressUpdate: progressHook.updateExercise,
|
||||
onStudySession: (date, minutes) => sessionsHook.recordSession(date, minutes),
|
||||
onAutoFork: ({ topic }) => {
|
||||
console.log('[App] auto-fork suggested:', topic);
|
||||
},
|
||||
onDifficultyChanged: ({ level }) => {
|
||||
console.log('[App] difficulty changed:', level);
|
||||
},
|
||||
});
|
||||
|
||||
const examHook = useExam(activeExam);
|
||||
const buddyHook = useStudyBuddy(buddyToken);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -57,8 +133,13 @@ export default function App() {
|
||||
// Load PDFs and progress
|
||||
pdfsHook.refresh();
|
||||
progressHook.refresh();
|
||||
examsHook.refresh();
|
||||
flashcardsHook.refresh();
|
||||
setHasLoadedConversations(true);
|
||||
setHasLoadedPdfs(true);
|
||||
} catch (err) {
|
||||
console.error('[App] load error:', err.message);
|
||||
toastError.current?.('Error al cargar los datos. Verifica que el servidor esté funcionando.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +153,63 @@ export default function App() {
|
||||
chatHook.setActiveId(conversationActiva.id);
|
||||
}, [conversationActiva?.id]);
|
||||
|
||||
// Auto-close mobile sidebar when resizing to desktop
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setMobileSidebarOpen(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e) {
|
||||
const tag = e.target?.tagName;
|
||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target?.isContentEditable;
|
||||
const ctrlOrMeta = e.ctrlKey || e.metaKey;
|
||||
|
||||
if (ctrlOrMeta) {
|
||||
if (isInput && e.key !== 'Enter') return;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'n':
|
||||
e.preventDefault();
|
||||
handleNewConversation();
|
||||
break;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
break;
|
||||
case 'enter':
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent('studyos:send-message'));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
if (forkActivo) {
|
||||
setForkActivo(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [forkActivo, handleNewConversation]);
|
||||
|
||||
// Listen for search result navigation: select conversation
|
||||
useEffect(() => {
|
||||
function handleSelectConversation(e) {
|
||||
const { id } = e.detail || {};
|
||||
if (!id) return;
|
||||
const conv = conversaciones.find((c) => c.id === id);
|
||||
if (conv) {
|
||||
setConversationActiva(conv);
|
||||
setForkActivo(null);
|
||||
}
|
||||
}
|
||||
window.addEventListener('studyos:select-conversation', handleSelectConversation);
|
||||
return () => window.removeEventListener('studyos:select-conversation', handleSelectConversation);
|
||||
}, [conversaciones]);
|
||||
|
||||
const handleSelectConversation = useCallback((conv) => {
|
||||
setConversationActiva(conv);
|
||||
setForkActivo(null);
|
||||
@@ -126,32 +264,118 @@ export default function App() {
|
||||
setForkActivo(null);
|
||||
}, []);
|
||||
|
||||
const handleAutoForkAccept = useCallback(() => {
|
||||
if (chatHook.autoForkPrompt?.topic) {
|
||||
handleFork(chatHook.autoForkPrompt.topic);
|
||||
chatHook.setAutoForkPrompt(null);
|
||||
}
|
||||
}, [chatHook.autoForkPrompt, handleFork, chatHook]);
|
||||
|
||||
const handleAutoForkDismiss = useCallback(() => {
|
||||
chatHook.setAutoForkPrompt(null);
|
||||
}, [chatHook]);
|
||||
|
||||
const handleShareConversation = useCallback(async () => {
|
||||
if (!conversationActiva) return;
|
||||
try {
|
||||
const data = await shareConversation(conversationActiva.id, 'compañero');
|
||||
setShareToken(data.token);
|
||||
} catch (err) {
|
||||
console.error('[App] share error:', err.message);
|
||||
}
|
||||
}, [conversationActiva]);
|
||||
|
||||
const handleJoinBuddy = useCallback(async (token) => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setBuddyToken(token);
|
||||
navigate(`/buddy/${token}`);
|
||||
} catch (err) {
|
||||
console.error('[App] join buddy error:', err.message);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleGenerateExam = useCallback(async (topic, pdfIds, numQuestions, durationSeconds) => {
|
||||
try {
|
||||
const data = await generateExam({
|
||||
conversation_id: conversationActiva?.id ?? null,
|
||||
topic,
|
||||
pdf_ids: pdfIds,
|
||||
num_questions: numQuestions,
|
||||
duration_seconds: durationSeconds,
|
||||
});
|
||||
setActiveExam(data);
|
||||
navigate(`/exam/${data.id}`);
|
||||
} catch (err) {
|
||||
console.error('[App] generate exam error:', err.message);
|
||||
}
|
||||
}, [conversationActiva, navigate]);
|
||||
|
||||
const handleRenameConversation = useCallback(async (id, title) => {
|
||||
try {
|
||||
await updateConversation(id, { title });
|
||||
} catch (err) {
|
||||
console.error('[App] rename conversation error:', err.message);
|
||||
}
|
||||
setConversaciones((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, title } : c))
|
||||
);
|
||||
if (conversationActiva?.id === id) {
|
||||
setConversationActiva((prev) => (prev ? { ...prev, title } : prev));
|
||||
}
|
||||
}, [conversationActiva]);
|
||||
|
||||
const handleRoadmapFork = useCallback((topic) => {
|
||||
setPendingPrompt(`Explica ${topic} en detalle`);
|
||||
setConversationActiva(null);
|
||||
navigate('/');
|
||||
}, [navigate]);
|
||||
|
||||
const mainConvs = conversaciones.filter((c) => c.type === 'main');
|
||||
|
||||
return (
|
||||
const isHome = location.pathname === '/';
|
||||
|
||||
const appContent = (
|
||||
<div className="app-layout">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<>
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed((s) => !s)}
|
||||
pdfs={pdfsHook.pdfs}
|
||||
progress={progressHook.progress}
|
||||
conversations={mainConvs}
|
||||
activeConversation={conversationActiva}
|
||||
notes={notas}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
onUploadPdf={pdfsHook.uploadPdf}
|
||||
onReorderPdf={pdfsHook.reorderPdf}
|
||||
onDeletePdf={pdfsHook.deletePdf}
|
||||
onResetTopic={progressHook.resetTopic}
|
||||
onNavigateSettings={() => navigate('/settings')}
|
||||
/>
|
||||
<main className="app-main">
|
||||
{isMobile && isHome && (
|
||||
<button
|
||||
className="hamburger-btn"
|
||||
onClick={() => setMobileSidebarOpen((s) => !s)}
|
||||
title="Toggle sidebar"
|
||||
style={{ position: 'absolute', top: 10, left: 10, zIndex: 35 }}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
)}
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed((s) => !s)}
|
||||
pdfs={pdfsHook.pdfs}
|
||||
progress={progressHook.progress}
|
||||
conversations={mainConvs}
|
||||
activeConversation={conversationActiva}
|
||||
notes={notas}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
onUploadPdf={pdfsHook.uploadPdf}
|
||||
onReorderPdf={pdfsHook.reorderPdf}
|
||||
onDeletePdf={pdfsHook.deletePdf}
|
||||
onResetTopic={progressHook.resetTopic}
|
||||
onNavigateSettings={() => navigate('/settings')}
|
||||
onNavigate={navigate}
|
||||
hasLoadedConversations={hasLoadedConversations}
|
||||
hasLoadedPdfs={hasLoadedPdfs}
|
||||
isMobileOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
searchInputRef={searchInputRef}
|
||||
dueCount={flashcardsHook.dueCount}
|
||||
/>
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<>
|
||||
<MainChat
|
||||
conversation={conversationActiva}
|
||||
modelo={modeloSeleccionado}
|
||||
@@ -161,6 +385,12 @@ export default function App() {
|
||||
messages={chatHook.messages}
|
||||
isStreaming={chatHook.isStreaming}
|
||||
MessageBubbleComponent={MessageBubble}
|
||||
onRenameConversation={handleRenameConversation}
|
||||
/>
|
||||
<AutoForkPrompt
|
||||
topic={chatHook.autoForkPrompt?.topic}
|
||||
onAccept={handleAutoForkAccept}
|
||||
onDismiss={handleAutoForkDismiss}
|
||||
/>
|
||||
<ChatInput
|
||||
onSend={(text, pdfIds, attachments) =>
|
||||
@@ -168,19 +398,44 @@ export default function App() {
|
||||
}
|
||||
isStreaming={chatHook.isStreaming}
|
||||
availablePdfs={pdfsHook.pdfs}
|
||||
prefillText={pendingPrompt}
|
||||
/>
|
||||
</main>
|
||||
<ForkPanel
|
||||
forkId={forkActivo?.id ?? null}
|
||||
forkTitle={forkActivo?.title || ''}
|
||||
onClose={handleCloseFork}
|
||||
onMerge={handleMergeFork}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/exams" element={<ExamHistory exams={examsHook.exams} onDelete={examsHook.remove} onRefresh={examsHook.refresh} onGenerate={handleGenerateExam} />} />
|
||||
<Route path="/exam/:id" element={<ExamPanel exam={activeExam} useExamHook={examHook} />} />
|
||||
<Route path="/flashcards" element={<FlashcardReview cards={flashcardsHook.cards} queue={flashcardsHook.queue} onRefresh={flashcardsHook.fetchNext} onMarkSeen={flashcardsHook.markSeen} onUpdate={flashcardsHook.update} onDelete={flashcardsHook.remove} />} />
|
||||
<Route path="/heatmap" element={<CalendarHeatmap heatmapData={sessionsHook.heatmapData} onRefresh={sessionsHook.heatmap} />} />
|
||||
<Route path="/roadmap" element={<RoadmapVisual onForkTopic={handleRoadmapFork} />} />
|
||||
<Route path="/timer" element={<PomodoroTimer />} />
|
||||
<Route path="/compare" element={<MultiPdfCompare />} />
|
||||
<Route path="/buddy/:token" element={<BuddyRouteWrapper
|
||||
onJoin={handleJoinBuddy}
|
||||
buddyHook={buddyHook}
|
||||
shareToken={shareToken}
|
||||
onShare={handleShareConversation}
|
||||
/>} />
|
||||
</Routes>
|
||||
</main>
|
||||
<ForkPanel
|
||||
forkId={forkActivo?.id ?? null}
|
||||
forkTitle={forkActivo?.title || ''}
|
||||
onClose={handleCloseFork}
|
||||
onMerge={handleMergeFork}
|
||||
width={forkWidth}
|
||||
onResize={setForkWidth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ToastBridge onReady={handleToastReady} />
|
||||
<ReactionsProvider>
|
||||
{appContent}
|
||||
</ReactionsProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
60
client/src/components/AutoForkPrompt.css
Normal file
60
client/src/components/AutoForkPrompt.css
Normal file
@@ -0,0 +1,60 @@
|
||||
.auto-fork-toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
max-width: 90vw;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.auto-fork-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auto-fork-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.auto-fork-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auto-fork-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auto-fork-btn {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.auto-fork-btn.accept {
|
||||
background: var(--accent-info);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.auto-fork-btn.dismiss {
|
||||
background: var(--text-tertiary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.auto-fork-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
25
client/src/components/AutoForkPrompt.jsx
Normal file
25
client/src/components/AutoForkPrompt.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import './AutoForkPrompt.css';
|
||||
|
||||
export default function AutoForkPrompt({ topic, onAccept, onDismiss }) {
|
||||
if (!topic) return null;
|
||||
|
||||
return (
|
||||
<div className="auto-fork-toast">
|
||||
<div className="auto-fork-content">
|
||||
<span className="auto-fork-icon">🍴</span>
|
||||
<span className="auto-fork-text">
|
||||
¿Querés practicar <strong>{topic}</strong>?
|
||||
</span>
|
||||
</div>
|
||||
<div className="auto-fork-actions">
|
||||
<button className="auto-fork-btn accept" onClick={onAccept}>
|
||||
Sí, practicar
|
||||
</button>
|
||||
<button className="auto-fork-btn dismiss" onClick={onDismiss}>
|
||||
Ahora no
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
client/src/components/CalendarHeatmap.jsx
Normal file
152
client/src/components/CalendarHeatmap.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
|
||||
export default function CalendarHeatmap({ heatmapData, onRefresh }) {
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh(365);
|
||||
}, [onRefresh]);
|
||||
|
||||
const { grid, maxMinutes, monthLabels } = useMemo(() => {
|
||||
const today = new Date();
|
||||
const days = [];
|
||||
for (let i = 364; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
days.push(d);
|
||||
}
|
||||
|
||||
const dataMap = new Map();
|
||||
for (const row of heatmapData) {
|
||||
dataMap.set(row.date, row.minutes);
|
||||
}
|
||||
|
||||
const weeks = [];
|
||||
const monthLbls = [];
|
||||
let lastMonth = -1;
|
||||
for (let w = 0; w < 53; w++) {
|
||||
const week = [];
|
||||
for (let dow = 0; dow < 7; dow++) {
|
||||
const dayIndex = w * 7 + dow - today.getDay();
|
||||
const day = days[Math.max(0, Math.min(days.length - 1, dayIndex + 364))];
|
||||
if (!day) {
|
||||
week.push(null);
|
||||
continue;
|
||||
}
|
||||
const iso = day.toISOString().split('T')[0];
|
||||
const minutes = dataMap.get(iso) || 0;
|
||||
if (day.getMonth() !== lastMonth && dow === 0) {
|
||||
monthLbls.push({ label: day.toLocaleString('default', { month: 'short' }), week: w });
|
||||
lastMonth = day.getMonth();
|
||||
}
|
||||
week.push({ date: iso, minutes, day });
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
|
||||
const maxMin = Math.max(1, ...heatmapData.map((d) => d.minutes)) || 1;
|
||||
return { grid: weeks, maxMinutes: maxMin, monthLabels: monthLbls };
|
||||
}, [heatmapData]);
|
||||
|
||||
const getIntensity = (minutes) => {
|
||||
if (minutes === 0) return 0;
|
||||
const ratio = minutes / maxMinutes;
|
||||
if (ratio <= 0.25) return 1;
|
||||
if (ratio <= 0.5) return 2;
|
||||
if (ratio <= 0.75) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const intensityColors = [
|
||||
'var(--bg-elevated)',
|
||||
'rgba(52,211,153,0.25)',
|
||||
'rgba(52,211,153,0.45)',
|
||||
'rgba(52,211,153,0.65)',
|
||||
'rgba(52,211,153,0.9)',
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 900, margin: '0 auto' }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20 }}>Actividad de Estudio</h2>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 720 }}>
|
||||
{/* Month labels */}
|
||||
<div style={{ display: 'flex', gap: 2, paddingLeft: 24, position: 'relative' }}>
|
||||
{monthLabels.map((m) => (
|
||||
<div key={`${m.label}-${m.week}`} style={{ position: 'absolute', left: m.week * 14 + 24, fontSize: 10, color: 'var(--text-tertiary)' }}>
|
||||
{m.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ height: 14 }} />
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
{/* Day labels */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginRight: 4 }}>
|
||||
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map((d, i) => (
|
||||
<div key={i} style={{ height: 12, fontSize: 9, color: 'var(--text-tertiary)', lineHeight: '12px' }}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Weeks */}
|
||||
{grid.map((week, wi) => (
|
||||
<div key={wi} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{week.map((day, di) => (
|
||||
<div
|
||||
key={di}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 2,
|
||||
background: day ? intensityColors[getIntensity(day.minutes)] : 'transparent',
|
||||
cursor: day ? 'pointer' : 'default',
|
||||
}}
|
||||
onMouseEnter={() => day && setHovered(day)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onMouseMove={(e) => setTooltipPos({ x: e.clientX, y: e.clientY })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>Menos</span>
|
||||
{intensityColors.map((c, i) => (
|
||||
<div key={i} style={{ width: 12, height: 12, borderRadius: 2, background: c }} />
|
||||
))}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>Más</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hovered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x + 16,
|
||||
top: tooltipPos.y + 16,
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-glow)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-primary)',
|
||||
zIndex: 60,
|
||||
pointerEvents: 'none',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{new Date(hovered.date).toLocaleDateString()}</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>{hovered.minutes} min</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Paperclip, X, ChevronDown, Check } from 'lucide-react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Send, Paperclip, X, ChevronDown, Check, Sigma } from 'lucide-react';
|
||||
import VoiceInput from './VoiceInput';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
|
||||
export default function ChatInput({ onSend, isStreaming, availablePdfs = [], prefillText = '' }) {
|
||||
const [text, setText] = useState('');
|
||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||
const [selectedPdfIds, setSelectedPdfIds] = useState([]);
|
||||
const [pdfDropdownOpen, setPdfDropdownOpen] = useState(false);
|
||||
const [latexMode, setLatexMode] = useState(false);
|
||||
const [latexPreview, setLatexPreview] = useState('');
|
||||
const [latexError, setLatexError] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
const latexDebounceRef = useRef(null);
|
||||
const prefillApplied = useRef(false);
|
||||
|
||||
// Auto-resize textarea (max ~5 lines ~120px)
|
||||
useEffect(() => {
|
||||
@@ -30,6 +38,51 @@ export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Apply prefill text once
|
||||
useEffect(() => {
|
||||
if (prefillText && !prefillApplied.current) {
|
||||
setText(prefillText);
|
||||
prefillApplied.current = true;
|
||||
}
|
||||
}, [prefillText]);
|
||||
|
||||
const handleSendRef = useRef(handleSend);
|
||||
handleSendRef.current = handleSend;
|
||||
|
||||
// Listen for custom send event from global shortcuts
|
||||
useEffect(() => {
|
||||
function handleCustomSend() {
|
||||
handleSendRef.current();
|
||||
}
|
||||
window.addEventListener('studyos:send-message', handleCustomSend);
|
||||
return () => window.removeEventListener('studyos:send-message', handleCustomSend);
|
||||
}, []);
|
||||
|
||||
// LaTeX preview debounce
|
||||
useEffect(() => {
|
||||
if (!latexMode) return;
|
||||
if (latexDebounceRef.current) clearTimeout(latexDebounceRef.current);
|
||||
latexDebounceRef.current = setTimeout(() => {
|
||||
if (!text.trim()) {
|
||||
setLatexPreview('');
|
||||
setLatexError(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const html = katex.renderToString(text, { throwOnError: false, displayMode: true });
|
||||
setLatexPreview(html);
|
||||
setLatexError(false);
|
||||
} catch {
|
||||
setLatexError(true);
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(latexDebounceRef.current);
|
||||
}, [text, latexMode]);
|
||||
|
||||
const appendTranscript = useCallback((transcript) => {
|
||||
setText((prev) => (prev ? prev + ' ' + transcript : transcript));
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -105,6 +158,9 @@ export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
|
||||
{/* Voice input */}
|
||||
<VoiceInput onTranscript={appendTranscript} disabled={isStreaming} />
|
||||
|
||||
{/* Attachment button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -193,12 +249,25 @@ export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe un mensaje..."
|
||||
placeholder={latexMode ? 'Escribe LaTeX...' : 'Escribe un mensaje...'}
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
className="chat-input-textarea"
|
||||
/>
|
||||
|
||||
{/* LaTeX toggle */}
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setLatexMode((s) => !s)}
|
||||
title={latexMode ? 'Cerrar editor LaTeX' : 'Editor LaTeX'}
|
||||
style={{
|
||||
color: latexMode ? 'var(--accent-purple)' : undefined,
|
||||
background: latexMode ? 'rgba(192,132,252,0.08)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Sigma size={18} />
|
||||
</button>
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
@@ -212,6 +281,28 @@ export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LaTeX preview panel */}
|
||||
{latexMode && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: `1px solid ${latexError ? 'var(--accent-coral)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '12px 16px',
|
||||
minHeight: 60,
|
||||
fontSize: 14,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{latexPreview ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: latexPreview }} />
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>Vista previa...</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
144
client/src/components/ExamHistory.jsx
Normal file
144
client/src/components/ExamHistory.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Trash2, Calendar, BookOpen, Tag, TrendingUp } from 'lucide-react';
|
||||
|
||||
export default function ExamHistory({ exams, onDelete, onRefresh }) {
|
||||
const [filterTopic, setFilterTopic] = useState('');
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, [onRefresh]);
|
||||
|
||||
const allTopics = useMemo(() => {
|
||||
const set = new Set();
|
||||
for (const e of exams) {
|
||||
if (Array.isArray(e.topics)) {
|
||||
e.topics.forEach((t) => set.add(t));
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [exams]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filterTopic) return exams;
|
||||
return exams.filter((e) => e.topics && e.topics.includes(filterTopic));
|
||||
}, [exams, filterTopic]);
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 900, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<BookOpen size={22} /> Historial de Exámenes
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<select
|
||||
value={filterTopic}
|
||||
onChange={(e) => setFilterTopic(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<option value="">Todos los temas</option>
|
||||
{allTopics.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
{filterTopic && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setFilterTopic('')}>
|
||||
Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="chat-empty-state" style={{ padding: 48 }}>
|
||||
<span style={{ fontSize: 32 }}>📝</span>
|
||||
<p>{exams.length === 0 ? 'Aún no has realizado exámenes.' : 'Ningún examen coincide con el filtro.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((exam) => {
|
||||
const isExpanded = expandedId === exam.id;
|
||||
return (
|
||||
<div
|
||||
key={exam.id}
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '14px 18px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
onClick={() => setExpandedId(isExpanded ? null : exam.id)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: '50%',
|
||||
background: exam.score >= 80 ? 'rgba(52,211,153,0.12)' : exam.score >= 50 ? 'rgba(251,191,36,0.12)' : 'rgba(248,113,113,0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: exam.score >= 80 ? 'var(--accent-green)' : exam.score >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)',
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<TrendingUp size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{exam.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
|
||||
<Calendar size={10} /> {formatDate(exam.taken_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, color: exam.score >= 80 ? 'var(--accent-green)' : exam.score >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)' }}>
|
||||
{exam.score}%
|
||||
</div>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(exam.id); }}
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 8 }}>Temas:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{Array.isArray(exam.topics) && exam.topics.map((t) => (
|
||||
<span key={t} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: 'var(--bg-surface)', padding: '4px 10px', borderRadius: 'var(--radius-pill)', fontSize: 12, border: '1px solid var(--border)' }}>
|
||||
<Tag size={10} /> {t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
client/src/components/ExamPanel.css
Normal file
154
client/src/components/ExamPanel.css
Normal file
@@ -0,0 +1,154 @@
|
||||
.exam-panel {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.exam-panel-empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exam-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.exam-timer {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timer-warning {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.exam-progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exam-progress-fill {
|
||||
height: 100%;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.exam-nav-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exam-question h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exam-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exam-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exam-option:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.exam-option.selected {
|
||||
border-color: var(--accent-info);
|
||||
background: rgba(74, 144, 217, 0.15);
|
||||
}
|
||||
|
||||
.exam-free-text {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.exam-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exam-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.exam-btn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.exam-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.exam-btn.submit {
|
||||
background: var(--accent-info);
|
||||
color: #fff;
|
||||
border-color: var(--accent-info);
|
||||
}
|
||||
|
||||
.exam-panel-results {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.exam-panel-results h2 {
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exam-result-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.exam-score {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-info);
|
||||
}
|
||||
|
||||
.exam-detail {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
107
client/src/components/ExamPanel.jsx
Normal file
107
client/src/components/ExamPanel.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import './ExamPanel.css';
|
||||
|
||||
export default function ExamPanel({ exam, useExamHook }) {
|
||||
const {
|
||||
currentQuestion,
|
||||
answers,
|
||||
remainingSeconds,
|
||||
status,
|
||||
result,
|
||||
setAnswer,
|
||||
goNext,
|
||||
goPrev,
|
||||
handleSubmit,
|
||||
percentTimeRemaining,
|
||||
} = useExamHook;
|
||||
|
||||
if (!exam || !exam.questions) {
|
||||
return <div className="exam-panel-empty">No hay examen activo</div>;
|
||||
}
|
||||
|
||||
const q = exam.questions[currentQuestion];
|
||||
const total = exam.questions.length;
|
||||
const answeredCount = answers.filter((a) => a !== null).length;
|
||||
|
||||
const formatTime = (s) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (status === 'submitted' || status === 'expired') {
|
||||
return (
|
||||
<div className="exam-panel-results">
|
||||
<h2>{status === 'submitted' ? 'Examen enviado' : 'Tiempo expirado'}</h2>
|
||||
{result && (
|
||||
<div className="exam-result-card">
|
||||
<div className="exam-score">{result.score}%</div>
|
||||
<div className="exam-detail">
|
||||
Correctas: {result.correct} / {result.total}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="exam-panel">
|
||||
<div className="exam-header">
|
||||
<div className="exam-timer">
|
||||
<span className={remainingSeconds < 60 ? 'timer-warning' : ''}>
|
||||
⏱ {formatTime(remainingSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="exam-progress-bar">
|
||||
<div
|
||||
className="exam-progress-fill"
|
||||
style={{ width: `${percentTimeRemaining}%`, background: remainingSeconds < 60 ? '#e74c3c' : '#4a90d9' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="exam-nav-info">
|
||||
Pregunta {currentQuestion + 1} de {total} · Respondidas: {answeredCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="exam-question">
|
||||
<h3>{q.q}</h3>
|
||||
{q.options && (
|
||||
<div className="exam-options">
|
||||
{q.options.map((opt, idx) => (
|
||||
<label key={idx} className={`exam-option ${answers[currentQuestion] === idx ? 'selected' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${currentQuestion}`}
|
||||
checked={answers[currentQuestion] === idx}
|
||||
onChange={() => setAnswer(currentQuestion, idx)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!q.options && (
|
||||
<textarea
|
||||
className="exam-free-text"
|
||||
placeholder="Escribí tu respuesta..."
|
||||
value={answers[currentQuestion] || ''}
|
||||
onChange={(e) => setAnswer(currentQuestion, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="exam-controls">
|
||||
<button className="exam-btn" onClick={goPrev} disabled={currentQuestion === 0}>
|
||||
← Anterior
|
||||
</button>
|
||||
<button className="exam-btn" onClick={goNext} disabled={currentQuestion === total - 1}>
|
||||
Siguiente →
|
||||
</button>
|
||||
<button className="exam-btn submit" onClick={handleSubmit} disabled={status !== 'running'}>
|
||||
Enviar examen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
client/src/components/FlashcardReview.jsx
Normal file
151
client/src/components/FlashcardReview.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { RotateCcw, Eye, Check, Trash2, Edit3, Save, X } from 'lucide-react';
|
||||
|
||||
export default function FlashcardReview({ cards, queue, onRefresh, onMarkSeen, onUpdate, onDelete }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editQ, setEditQ] = useState('');
|
||||
const [editA, setEditA] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh({ seen: 0 });
|
||||
}, [onRefresh]);
|
||||
|
||||
const current = queue[currentIndex];
|
||||
const total = queue.length;
|
||||
|
||||
const handleReveal = () => setRevealed(true);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setRevealed(false);
|
||||
setEditingId(null);
|
||||
setCurrentIndex((i) => (i + 1 < queue.length ? i + 1 : i));
|
||||
}, [queue.length]);
|
||||
|
||||
const handleMarkSeen = useCallback(async () => {
|
||||
if (!current) return;
|
||||
await onMarkSeen(current.id);
|
||||
setRevealed(false);
|
||||
setEditingId(null);
|
||||
setCurrentIndex((i) => (i < queue.length - 1 ? i : Math.max(0, queue.length - 2)));
|
||||
}, [current, onMarkSeen, queue.length]);
|
||||
|
||||
const handleEditStart = (card) => {
|
||||
setEditingId(card.id);
|
||||
setEditQ(card.question);
|
||||
setEditA(card.answer);
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!editingId) return;
|
||||
await onUpdate(editingId, { question: editQ, answer: editA });
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const progress = total > 0 ? Math.round(((currentIndex + (revealed ? 1 : 0)) / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 640, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Flashcards</h2>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
{cards.filter((c) => c.seen).length} / {cards.length} vistas
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="progress-bar-bg" style={{ height: 6 }}>
|
||||
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--accent-green)' }} />
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<div className="chat-empty-state" style={{ padding: 48 }}>
|
||||
<span style={{ fontSize: 32 }}>🎉</span>
|
||||
<p>¡No hay flashcards pendientes!</p>
|
||||
</div>
|
||||
) : current ? (
|
||||
<div
|
||||
style={{
|
||||
perspective: 1000,
|
||||
minHeight: 220,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: '24px',
|
||||
minHeight: 200,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease',
|
||||
transform: revealed ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
transformStyle: 'preserve-3d',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Front */}
|
||||
<div style={{ backfaceVisibility: 'hidden', position: 'absolute', inset: 0, padding: '24px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
|
||||
{editingId === current.id ? (
|
||||
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<textarea value={editQ} onChange={(e) => setEditQ(e.target.value)} rows={3} style={{ width: '100%', background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', padding: 10, fontSize: 14, fontFamily: 'var(--font-ui)' }} />
|
||||
<textarea value={editA} onChange={(e) => setEditA(e.target.value)} rows={3} style={{ width: '100%', background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', padding: 10, fontSize: 14, fontFamily: 'var(--font-ui)' }} />
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={handleEditSave}><Save size={12} /> Guardar</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setEditingId(null)}><X size={12} /> Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, marginBottom: 16 }}>{current.question}</div>
|
||||
<button className="btn btn-primary" onClick={handleReveal}><Eye size={14} /> Revelar respuesta</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back */}
|
||||
<div style={{ backfaceVisibility: 'hidden', position: 'absolute', inset: 0, padding: '24px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', transform: 'rotateY(180deg)' }}>
|
||||
<div style={{ fontSize: 16, lineHeight: 1.6, marginBottom: 20 }}>{current.answer}</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button className="btn btn-sm btn-secondary" onClick={handleNext}><RotateCcw size={12} /> De nuevo</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={handleMarkSeen}><Check size={12} /> Vista</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Controls */}
|
||||
{current && editingId !== current.id && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 10 }}>
|
||||
<button className="icon-btn" onClick={() => handleEditStart(current)} title="Editar"><Edit3 size={16} /></button>
|
||||
<button className="icon-btn" onClick={() => onDelete(current.id)} title="Eliminar"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All cards list */}
|
||||
{cards.length > 0 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>Todas las flashcards</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{cards.map((card) => (
|
||||
<div key={card.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg-surface)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)', fontSize: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{card.question}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 10, color: card.seen ? 'var(--accent-green)' : 'var(--text-tertiary)' }}>{card.seen ? 'Vista' : 'Nueva'}</span>
|
||||
<button className="icon-btn" style={{ padding: 4 }} onClick={() => handleEditStart(card)} title="Editar"><Edit3 size={12} /></button>
|
||||
<button className="icon-btn" style={{ padding: 4 }} onClick={() => onDelete(card.id)} title="Eliminar"><Trash2 size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,37 +2,13 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { GitBranch, X, GitMerge, Loader2, Send } from 'lucide-react';
|
||||
import useChat from '../hooks/useChat';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import { TypingDots } from './TypingDots';
|
||||
|
||||
function StreamingIndicator() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 8px',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent-green)',
|
||||
animation: 'pulse-dot 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<span>Pensando...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ForkPanel({ forkId, forkTitle, onClose, onMerge }) {
|
||||
export default function ForkPanel({ forkId, forkTitle, onClose, onMerge, width = 320, onResize }) {
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const messagesEndRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
|
||||
const chatHook = useChat({
|
||||
conversationId: forkId ?? null,
|
||||
@@ -43,7 +19,7 @@ export default function ForkPanel({ forkId, forkTitle, onClose, onMerge }) {
|
||||
chatHook.setActiveId(forkId);
|
||||
setInputText('');
|
||||
}
|
||||
}, [forkId]);
|
||||
}, [forkId, chatHook.setActiveId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
@@ -75,136 +51,165 @@ export default function ForkPanel({ forkId, forkTitle, onClose, onMerge }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e) => {
|
||||
if (!onResize) return;
|
||||
const startX = e.clientX;
|
||||
const startWidth = width;
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
const deltaX = startX - moveEvent.clientX;
|
||||
const newWidth = Math.min(480, Math.max(240, startWidth + deltaX));
|
||||
onResize(newWidth);
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent) => {
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
(upEvent.target).releasePointerCapture?.(upEvent.pointerId);
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
e.target.setPointerCapture?.(e.pointerId);
|
||||
};
|
||||
|
||||
const isOpen = forkId !== null;
|
||||
|
||||
return (
|
||||
<aside className={`fork-panel ${isOpen ? 'open' : ''}`}>
|
||||
<aside className={`fork-panel ${isOpen ? 'open' : ''}`} ref={panelRef} style={{ width: isOpen ? width : undefined }}>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexDirection: 'row',
|
||||
height: '100%',
|
||||
width: 280,
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="fork-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
|
||||
<GitBranch size={14} style={{ color: 'var(--accent-amber)', flexShrink: 0 }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={forkTitle}
|
||||
>
|
||||
{forkTitle || 'Fork'}
|
||||
</span>
|
||||
<div
|
||||
className="fork-resize-handle"
|
||||
onPointerDown={handlePointerDown}
|
||||
title="Arrastrar para redimensionar"
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div className="fork-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
|
||||
<GitBranch size={14} style={{ color: 'var(--accent-amber)', flexShrink: 0 }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={forkTitle}
|
||||
>
|
||||
{forkTitle || 'Fork'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={handleMerge}
|
||||
disabled={merging}
|
||||
className="fork-merge-btn"
|
||||
>
|
||||
{merging ? (
|
||||
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
) : (
|
||||
<GitMerge size={12} />
|
||||
)}
|
||||
{merging ? 'Merging...' : 'Merge & Close'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="fork-close-btn"
|
||||
title="Close without merge"
|
||||
>
|
||||
<X size={14} />
|
||||
<span style={{ marginLeft: 2 }}>Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={handleMerge}
|
||||
disabled={merging}
|
||||
className="fork-merge-btn"
|
||||
>
|
||||
{merging ? (
|
||||
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
) : (
|
||||
<GitMerge size={12} />
|
||||
)}
|
||||
{merging ? 'Merging...' : 'Merge & Close'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="fork-close-btn"
|
||||
title="Close without merge"
|
||||
>
|
||||
<X size={14} />
|
||||
<span style={{ marginLeft: 2 }}>Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact chat area */}
|
||||
<div className="fork-chat">
|
||||
{chatHook.messages.length === 0 && (
|
||||
<div
|
||||
{/* Compact chat area */}
|
||||
<div className="fork-chat">
|
||||
{chatHook.messages.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 11,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
Escribe un mensaje para comenzar
|
||||
</div>
|
||||
)}
|
||||
{chatHook.messages.map((msg) => (
|
||||
<MessageBubble key={msg.id ?? msg.created_at} message={msg} />
|
||||
))}
|
||||
{chatHook.isStreaming && <TypingDots />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Compact chat input */}
|
||||
<div style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '6px 8px',
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'flex-end',
|
||||
background: 'var(--bg-surface)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<textarea
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe..."
|
||||
rows={1}
|
||||
disabled={chatHook.isStreaming}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: 12,
|
||||
padding: '4px 8px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
maxHeight: 60,
|
||||
}}
|
||||
onInput={(e) => {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 60) + 'px';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputText.trim() || chatHook.isStreaming}
|
||||
style={{
|
||||
background: inputText.trim() && !chatHook.isStreaming ? 'var(--accent-green)' : 'var(--bg-elevated)',
|
||||
color: inputText.trim() && !chatHook.isStreaming ? '#fff' : 'var(--text-tertiary)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '6px',
|
||||
cursor: inputText.trim() && !chatHook.isStreaming ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 11,
|
||||
padding: 16,
|
||||
opacity: inputText.trim() && !chatHook.isStreaming ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Escribe un mensaje para comenzar
|
||||
</div>
|
||||
)}
|
||||
{chatHook.messages.map((msg) => (
|
||||
<MessageBubble key={msg.id ?? msg.created_at} message={msg} />
|
||||
))}
|
||||
{chatHook.isStreaming && <StreamingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Compact chat input */}
|
||||
<div style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '6px 8px',
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'flex-end',
|
||||
background: 'var(--bg-surface)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<textarea
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe..."
|
||||
rows={1}
|
||||
disabled={chatHook.isStreaming}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: 12,
|
||||
padding: '4px 8px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
maxHeight: 60,
|
||||
}}
|
||||
onInput={(e) => {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 60) + 'px';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputText.trim() || chatHook.isStreaming}
|
||||
style={{
|
||||
background: inputText.trim() && !chatHook.isStreaming ? 'var(--accent-green)' : 'var(--bg-elevated)',
|
||||
color: inputText.trim() && !chatHook.isStreaming ? '#fff' : 'var(--text-tertiary)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '6px',
|
||||
cursor: inputText.trim() && !chatHook.isStreaming ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: inputText.trim() && !chatHook.isStreaming ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
95
client/src/components/LatexEditor.jsx
Normal file
95
client/src/components/LatexEditor.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { Sigma, X } from 'lucide-react';
|
||||
|
||||
export default function LatexEditor({ text, onTextChange, onSend, disabled }) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
const renderPreview = useCallback((value) => {
|
||||
if (!value.trim()) {
|
||||
setPreviewHtml('');
|
||||
setPreviewError(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const html = katex.renderToString(value, { throwOnError: false, displayMode: true });
|
||||
setPreviewHtml(html);
|
||||
setPreviewError(false);
|
||||
} catch {
|
||||
setPreviewError(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
renderPreview(text);
|
||||
}, 200);
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, [text, active, renderPreview]);
|
||||
|
||||
const handleToggle = () => {
|
||||
setActive((a) => !a);
|
||||
if (!active) {
|
||||
renderPreview(text);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
placeholder={active ? 'Escribe LaTeX...' : 'Escribe un mensaje...'}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
className="chat-input-textarea"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={handleToggle}
|
||||
title={active ? 'Cerrar editor LaTeX' : 'Editor LaTeX'}
|
||||
style={{
|
||||
color: active ? 'var(--accent-purple)' : undefined,
|
||||
background: active ? 'rgba(192,132,252,0.08)' : undefined,
|
||||
}}
|
||||
>
|
||||
{active ? <X size={18} /> : <Sigma size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: `1px solid ${previewError ? 'var(--accent-coral)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '12px 16px',
|
||||
minHeight: 60,
|
||||
fontSize: 14,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{previewHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>Vista previa...</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { MessageSquare, GitBranch, X, Maximize2 } from 'lucide-react';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import ModelSelector from './ModelSelector';
|
||||
import { TypingDots } from './TypingDots';
|
||||
import ScrollFab from './ScrollFab';
|
||||
|
||||
function CoordinatePlane({ data }) {
|
||||
try {
|
||||
@@ -34,15 +36,6 @@ function CoordinatePlane({ data }) {
|
||||
} catch { return <div style={{ color: 'var(--accent-coral)', fontSize: 11 }}>Error al renderizar</div>; }
|
||||
}
|
||||
|
||||
function StreamingIndicator() {
|
||||
return (
|
||||
<div className="streaming-indicator">
|
||||
<span className="streaming-indicator-dot" />
|
||||
<span>Pensando...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ hasConversation }) {
|
||||
return (
|
||||
<div className="chat-empty-state">
|
||||
@@ -89,30 +82,16 @@ function GraphPanel({ messages, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
function extractGraphs(messages) {
|
||||
const graphs = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'assistant') continue;
|
||||
const content = msg.content || '';
|
||||
const regex = /```graph\n([\s\S]*?)```/g;
|
||||
const matches = [...content.matchAll(regex)];
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const data = JSON.parse(match[1]);
|
||||
graphs.push({ data, msgId: msg.id, element: null });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return graphs;
|
||||
}
|
||||
|
||||
export default function MainChat({
|
||||
conversation, modelo, modelos, onModelChange, onFork,
|
||||
messages, isStreaming, MessageBubbleComponent = MessageBubble,
|
||||
onRenameConversation,
|
||||
}) {
|
||||
const messagesEndRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const [graphPanelOpen, setGraphPanelOpen] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const titleInputRef = useRef(null);
|
||||
|
||||
const handleForkFromMessage = (topic) => { if (onFork) onFork(topic); };
|
||||
|
||||
@@ -126,15 +105,60 @@ export default function MainChat({
|
||||
}
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
const currentTitle = conversation?.title || 'Selecciona una conversación';
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (!conversation) return;
|
||||
setEditingTitle(true);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
const input = titleInputRef.current;
|
||||
if (!input || !conversation) {
|
||||
setEditingTitle(false);
|
||||
return;
|
||||
}
|
||||
const newTitle = input.value.trim();
|
||||
if (newTitle && newTitle !== currentTitle) {
|
||||
onRenameConversation?.(conversation.id, newTitle);
|
||||
}
|
||||
setEditingTitle(false);
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100%', overflow: 'hidden', background: 'var(--bg-base)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100%', overflow: 'hidden', background: 'var(--bg-base)', position: 'relative' }}>
|
||||
{/* Topbar */}
|
||||
<div className="mainchat-topbar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={conversation?.title}>
|
||||
{conversation?.title || 'Selecciona una conversación'}
|
||||
</h2>
|
||||
{editingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
className="input-rename"
|
||||
defaultValue={currentTitle}
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
onClick={handleTitleClick}
|
||||
style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: conversation ? 'pointer' : 'default' }}
|
||||
title={conversation ? 'Click para renombrar' : currentTitle}
|
||||
>
|
||||
{currentTitle}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<ModelSelector selectedModelId={modelo?.id ?? null} onSelect={(id) => { const s = modelos.find(m => m.id === id); onModelChange(s || null); }} models={modelos} />
|
||||
@@ -153,9 +177,11 @@ export default function MainChat({
|
||||
{messages.map((msg) => (
|
||||
<MessageBubbleComponent key={msg.id ?? msg.created_at} message={msg} onFork={msg.role === 'assistant' ? handleForkFromMessage : undefined} />
|
||||
))}
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
{isStreaming && <TypingDots />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<ScrollFab containerRef={containerRef} threshold={200} />
|
||||
</div>
|
||||
{graphPanelOpen && hasGraphs && <GraphPanel messages={messages} onClose={() => setGraphPanelOpen(false)} />}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useEffect, useState, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { GitMerge, GitBranch } from 'lucide-react';
|
||||
import { GitMerge, GitBranch, Copy, Check } from 'lucide-react';
|
||||
import katex from 'katex';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/themes/prism-tomorrow.css';
|
||||
import { useReactions } from '../context/ReactionsContext';
|
||||
|
||||
function LatexRenderer({ text }) {
|
||||
const parts = useMemo(() => {
|
||||
@@ -26,9 +29,9 @@ function LatexRenderer({ text }) {
|
||||
|
||||
return parts.map((part, i) =>
|
||||
part.type === 'latex' ? (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: part.html }}
|
||||
<span key={`${i}-${part.type}`} dangerouslySetInnerHTML={{ __html: part.html }}
|
||||
style={part.isBlock ? { display: 'block', margin: '12px 0', textAlign: 'center' } : {}} />
|
||||
) : <React.Fragment key={i}>{part.content}</React.Fragment>
|
||||
) : <React.Fragment key={`${i}-${part.type}`}>{part.content}</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +73,36 @@ function CoordinatePlane({ data }) {
|
||||
} catch { return <div style={{ color: 'var(--accent-coral)', fontSize: 12 }}>Error al renderizar gráfico</div>; }
|
||||
}
|
||||
|
||||
function CodeBlock({ className, children }) {
|
||||
const codeRef = useRef(null);
|
||||
const code = String(children).trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeRef.current || !className) return;
|
||||
const langMatch = className.match(/language-(\w+)/);
|
||||
const lang = langMatch ? langMatch[1] : null;
|
||||
if (lang && Prism.languages[lang]) {
|
||||
codeRef.current.innerHTML = Prism.highlight(code, Prism.languages[lang], lang);
|
||||
}
|
||||
}, [code, className]);
|
||||
|
||||
if (!className || !className.startsWith('language-')) {
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
|
||||
const langMatch = className.match(/language-(\w+)/);
|
||||
const lang = langMatch ? langMatch[1] : null;
|
||||
const canHighlight = lang && Prism.languages[lang];
|
||||
|
||||
return (
|
||||
<pre>
|
||||
<code className={className} ref={canHighlight ? codeRef : null}>
|
||||
{canHighlight ? '' : children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownContent({ content }) {
|
||||
return (
|
||||
<div className="markdown-body">
|
||||
@@ -80,11 +113,16 @@ function MarkdownContent({ content }) {
|
||||
if (className === 'language-graph') return <CoordinatePlane data={code} />;
|
||||
const isInline = !className || !className.startsWith('language-');
|
||||
if (isInline) return <code {...props}>{children}</code>;
|
||||
return <pre><code className={className} {...props}>{children}</code></pre>;
|
||||
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
},
|
||||
p({ children }) {
|
||||
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.map(c => typeof c === 'string' ? c : '').join('') : '';
|
||||
return <p style={{ marginBottom: 8 }}><LatexRenderer text={text} /></p>;
|
||||
const arr = React.Children.toArray(children);
|
||||
const hasRichContent = arr.some(c => typeof c !== 'string');
|
||||
if (!hasRichContent) {
|
||||
const text = arr.join('');
|
||||
return <p style={{ marginBottom: 8 }}><LatexRenderer text={text} /></p>;
|
||||
}
|
||||
return <p style={{ marginBottom: 8 }}>{children}</p>;
|
||||
},
|
||||
h1({ children }) { return <h1 style={{ fontSize: 18, fontWeight: 700, marginBottom: 10, marginTop: 6, background: 'linear-gradient(135deg, var(--accent-green), var(--accent-info))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>{children}</h1>; },
|
||||
h2({ children }) { return <h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 8, marginTop: 6 }}>{children}</h2>; },
|
||||
@@ -103,7 +141,11 @@ function MarkdownContent({ content }) {
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message, onFork }) {
|
||||
const { role, content, created_at } = message;
|
||||
const { role, content, created_at, id } = message;
|
||||
const reactions = useReactions();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef(null);
|
||||
|
||||
if (role === 'system') return null;
|
||||
if (!content) return null;
|
||||
|
||||
@@ -117,8 +159,22 @@ export default function MessageBubble({ message, onFork }) {
|
||||
}
|
||||
|
||||
const isUser = role === 'user';
|
||||
const msgId = id ?? created_at;
|
||||
const userReaction = !isUser ? reactions.get(msgId) : null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message-bubble" style={{ display: 'flex', justifyContent: isUser ? 'flex-end' : 'flex-start', width: '100%' }}>
|
||||
<div className="message-bubble" style={{ display: 'flex', justifyContent: isUser ? 'flex-end' : 'flex-start', width: '100%', position: 'relative' }}>
|
||||
<div style={{ maxWidth: '80%', padding: isUser ? '10px 14px' : '0', background: isUser ? 'var(--bg-elevated)' : 'transparent', borderRadius: isUser ? '12px 12px 4px 12px' : '0', color: 'var(--text-primary)', fontSize: 14, lineHeight: 1.5, wordBreak: 'break-word' }}>
|
||||
{isUser ? <div>{content}</div> : <MarkdownContent content={content} />}
|
||||
{!isUser && onFork && (
|
||||
@@ -139,8 +195,32 @@ export default function MessageBubble({ message, onFork }) {
|
||||
<GitBranch size={11} /> Fork
|
||||
</button>
|
||||
)}
|
||||
{!isUser && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 }}>
|
||||
<button
|
||||
className={`reaction-pill ${userReaction === '👍' ? 'reaction-pill--active' : ''}`}
|
||||
onClick={() => reactions.toggle(msgId, '👍')}
|
||||
title="Me gusta"
|
||||
>
|
||||
👍
|
||||
</button>
|
||||
<button
|
||||
className={`reaction-pill ${userReaction === '👎' ? 'reaction-pill--active' : ''}`}
|
||||
onClick={() => reactions.toggle(msgId, '👎')}
|
||||
title="No me gusta"
|
||||
>
|
||||
👎
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{created_at && <div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginTop: 4, textAlign: isUser ? 'right' : 'left' }}>{new Date(created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>}
|
||||
</div>
|
||||
{!isUser && (
|
||||
<button className="copy-btn" onClick={handleCopy} title="Copiar mensaje">
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
{copied ? 'Copiado' : 'Copiar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
149
client/src/components/MultiPdfCompare.jsx
Normal file
149
client/src/components/MultiPdfCompare.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
|
||||
function ComparePanel({ url, side, onReplace, onClear, onFileSelected, inputRef }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg-surface)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||
{side === 'left' ? 'PDF A' : 'PDF B'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="icon-btn" style={{ padding: 4 }} onClick={onReplace} title="Reemplazar">
|
||||
<Upload size={14} />
|
||||
</button>
|
||||
{url && (
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ padding: 4 }}
|
||||
onClick={onClear}
|
||||
title="Quitar"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
{url ? (
|
||||
<iframe
|
||||
src={url}
|
||||
style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }}
|
||||
title={`PDF ${side}`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-tertiary)',
|
||||
gap: 8,
|
||||
padding: 40,
|
||||
}}
|
||||
>
|
||||
<Upload size={28} opacity={0.4} />
|
||||
<span style={{ fontSize: 13 }}>Arrastra o sube un PDF</span>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => inputRef.current?.click()}>
|
||||
Seleccionar archivo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onFileSelected(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MultiPdfCompare() {
|
||||
const [leftUrl, setLeftUrl] = useState(null);
|
||||
const [rightUrl, setRightUrl] = useState(null);
|
||||
const leftInputRef = useRef(null);
|
||||
const rightInputRef = useRef(null);
|
||||
|
||||
const handleReplace = (side) => {
|
||||
if (side === 'left') {
|
||||
leftInputRef.current?.click();
|
||||
} else {
|
||||
rightInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = (side) => {
|
||||
if (side === 'left') {
|
||||
if (leftUrl) URL.revokeObjectURL(leftUrl);
|
||||
setLeftUrl(null);
|
||||
} else {
|
||||
if (rightUrl) URL.revokeObjectURL(rightUrl);
|
||||
setRightUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = useCallback((file, side) => {
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
if (side === 'left') {
|
||||
if (leftUrl) URL.revokeObjectURL(leftUrl);
|
||||
setLeftUrl(url);
|
||||
} else {
|
||||
if (rightUrl) URL.revokeObjectURL(rightUrl);
|
||||
setRightUrl(url);
|
||||
}
|
||||
}, [leftUrl, rightUrl]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', height: 'calc(100vh - 48px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>Comparar PDFs</h2>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 16, minHeight: 0 }}>
|
||||
<ComparePanel
|
||||
url={leftUrl}
|
||||
side="left"
|
||||
onReplace={() => handleReplace('left')}
|
||||
onClear={() => handleClear('left')}
|
||||
onFileSelected={(file) => handleFile(file, 'left')}
|
||||
inputRef={leftInputRef}
|
||||
/>
|
||||
<ComparePanel
|
||||
url={rightUrl}
|
||||
side="right"
|
||||
onReplace={() => handleReplace('right')}
|
||||
onClear={() => handleClear('right')}
|
||||
onFileSelected={(file) => handleFile(file, 'right')}
|
||||
inputRef={rightInputRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
client/src/components/PomodoroTimer.jsx
Normal file
137
client/src/components/PomodoroTimer.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Play, Pause, RotateCcw, Clock } from 'lucide-react';
|
||||
|
||||
const STUDY_MINUTES = 25;
|
||||
const BREAK_MINUTES = 5;
|
||||
|
||||
export default function PomodoroTimer() {
|
||||
const [phase, setPhase] = useState('study'); // 'study' | 'break'
|
||||
const [state, setState] = useState('idle'); // 'idle' | 'running' | 'paused'
|
||||
const [remaining, setRemaining] = useState(STUDY_MINUTES * 60);
|
||||
const intervalRef = useRef(null);
|
||||
const phaseRef = useRef(phase);
|
||||
|
||||
useEffect(() => {
|
||||
phaseRef.current = phase;
|
||||
}, [phase]);
|
||||
|
||||
const notify = useCallback((title, body) => {
|
||||
if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification(title, { body });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPermission = useCallback(() => {
|
||||
if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission().catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(() => {
|
||||
requestPermission();
|
||||
setState('running');
|
||||
clearTimer();
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Phase transition using ref to avoid stale closure
|
||||
const nextPhase = phaseRef.current === 'study' ? 'break' : 'study';
|
||||
const nextDuration = nextPhase === 'study' ? STUDY_MINUTES * 60 : BREAK_MINUTES * 60;
|
||||
setPhase(nextPhase);
|
||||
if (nextPhase === 'break') {
|
||||
notify('Pomodoro', '¡Tiempo de descanso! 5 minutos.');
|
||||
} else {
|
||||
notify('Pomodoro', '¡De vuelta al estudio! 25 minutos.');
|
||||
}
|
||||
return nextDuration;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}, [clearTimer, notify, requestPermission]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
clearTimer();
|
||||
setState('paused');
|
||||
}, [clearTimer]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearTimer();
|
||||
setState('idle');
|
||||
setPhase('study');
|
||||
setRemaining(STUDY_MINUTES * 60);
|
||||
}, [clearTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimer();
|
||||
}, [clearTimer]);
|
||||
|
||||
const minutes = Math.floor(remaining / 60);
|
||||
const seconds = remaining % 60;
|
||||
const display = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
|
||||
const progress = phase === 'study'
|
||||
? ((STUDY_MINUTES * 60 - remaining) / (STUDY_MINUTES * 60)) * 100
|
||||
: ((BREAK_MINUTES * 60 - remaining) / (BREAK_MINUTES * 60)) * 100;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 480, margin: '0 auto', textAlign: 'center' }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<Clock size={22} /> Pomodoro
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 24px',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: `conic-gradient(var(--accent-green) ${progress}%, var(--bg-elevated) ${progress}%)`,
|
||||
boxShadow: '0 0 40px rgba(52,211,153,0.1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 190,
|
||||
height: 190,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 42, fontWeight: 700, fontFamily: 'var(--font-mono)', letterSpacing: '0.05em' }}>{display}</div>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||
{phase === 'study' ? 'Estudio' : 'Descanso'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12 }}>
|
||||
{state === 'running' ? (
|
||||
<button className="btn btn-secondary" onClick={pause}><Pause size={16} /> Pausar</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={start}><Play size={16} /> {state === 'paused' ? 'Continuar' : 'Iniciar'}</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={reset}><RotateCcw size={16} /> Reiniciar</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
client/src/components/RoadmapVisual.jsx
Normal file
133
client/src/components/RoadmapVisual.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import roadmapData from '../data/roadmap.json';
|
||||
|
||||
export default function RoadmapVisual({ onForkTopic }) {
|
||||
const [selectedDomain, setSelectedDomain] = useState(null);
|
||||
|
||||
const { nodes, edges, domains } = useMemo(() => {
|
||||
const allTopics = [];
|
||||
const domainMap = new Map();
|
||||
roadmapData.domains.forEach((d) => {
|
||||
domainMap.set(d.name, d.color);
|
||||
d.topics.forEach((t) => {
|
||||
allTopics.push({ ...t, domain: d.name, color: d.color });
|
||||
});
|
||||
});
|
||||
|
||||
// Deterministic layout: concentric rings by domain
|
||||
const centerX = 400;
|
||||
const centerY = 300;
|
||||
const domainRadius = { Matemáticas: 100, Ciencias: 180, Informática: 260, Humanidades: 340 };
|
||||
|
||||
const nodes = allTopics.map((t, i) => {
|
||||
const domainTopics = allTopics.filter((x) => x.domain === t.domain);
|
||||
const idxInDomain = domainTopics.findIndex((x) => x.id === t.id);
|
||||
const count = domainTopics.length;
|
||||
const radius = domainRadius[t.domain] || 200;
|
||||
const angle = (idxInDomain / count) * Math.PI * 2 - Math.PI / 2;
|
||||
return {
|
||||
...t,
|
||||
x: centerX + Math.cos(angle) * radius,
|
||||
y: centerY + Math.sin(angle) * radius,
|
||||
};
|
||||
});
|
||||
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
const edges = roadmapData.edges
|
||||
.filter((e) => nodeMap.has(e.from) && nodeMap.has(e.to))
|
||||
.map((e) => ({ from: nodeMap.get(e.from), to: nodeMap.get(e.to) }));
|
||||
|
||||
return { nodes, edges, domains: roadmapData.domains };
|
||||
}, []);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(node) => {
|
||||
if (onForkTopic) onForkTopic(node.name);
|
||||
},
|
||||
[onForkTopic]
|
||||
);
|
||||
|
||||
const filteredNodes = selectedDomain ? nodes.filter((n) => n.domain === selectedDomain) : nodes;
|
||||
const filteredEdges = selectedDomain
|
||||
? edges.filter((e) => e.from.domain === selectedDomain && e.to.domain === selectedDomain)
|
||||
: edges;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 900, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Mapa de Temas</h2>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className={`btn btn-sm ${selectedDomain === null ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setSelectedDomain(null)}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
{domains.map((d) => (
|
||||
<button
|
||||
key={d.name}
|
||||
className={`btn btn-sm ${selectedDomain === d.name ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setSelectedDomain(d.name)}
|
||||
style={selectedDomain === d.name ? { background: d.color, color: '#0f1117' } : {}}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
|
||||
<svg viewBox="0 0 800 600" style={{ width: '100%', height: 'auto', display: 'block' }}>
|
||||
{/* Edges */}
|
||||
{filteredEdges.map((e, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={e.from.x}
|
||||
y1={e.from.y}
|
||||
x2={e.to.x}
|
||||
y2={e.to.y}
|
||||
stroke="var(--border-glow)"
|
||||
strokeWidth={1.5}
|
||||
opacity={0.6}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Nodes */}
|
||||
{filteredNodes.map((node) => (
|
||||
<g key={node.id} onClick={() => handleNodeClick(node)} style={{ cursor: 'pointer' }}>
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={28}
|
||||
fill={node.color + '20'}
|
||||
stroke={node.color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + 4}
|
||||
textAnchor="middle"
|
||||
fill="var(--text-primary)"
|
||||
fontSize={11}
|
||||
fontFamily="var(--font-ui)"
|
||||
fontWeight={600}
|
||||
>
|
||||
{node.name}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 14, marginTop: 16 }}>
|
||||
{domains.map((d) => (
|
||||
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: d.color }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{d.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
client/src/components/ScrollFab.jsx
Normal file
39
client/src/components/ScrollFab.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
|
||||
export default function ScrollFab({ containerRef, threshold = 200 }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef?.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
setVisible(distanceFromBottom > threshold);
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [containerRef, threshold]);
|
||||
|
||||
const handleClick = () => {
|
||||
const container = containerRef?.current;
|
||||
if (!container) return;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`scroll-fab ${visible ? 'scroll-fab--visible' : ''}`}
|
||||
onClick={handleClick}
|
||||
aria-label="Scroll to new messages"
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
<span>Nuevos mensajes</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
281
client/src/components/SearchBar.jsx
Normal file
281
client/src/components/SearchBar.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Search, X, MessageSquare, FileText } from 'lucide-react';
|
||||
import useSearch from '../hooks/useSearch';
|
||||
|
||||
export default function SearchBar({ inputRef, onNavigate }) {
|
||||
const { query, setQuery, results, loading, clear } = useSearch();
|
||||
const containerRef = useRef(null);
|
||||
const timeoutRef = useRef(null);
|
||||
const hasResults = results.length > 0;
|
||||
const isOpen = query.trim().length > 0;
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}
|
||||
}, [isOpen, clear]);
|
||||
|
||||
const messageResults = results.filter((r) => r.type === 'message');
|
||||
const pdfResults = results.filter((r) => r.type === 'pdf');
|
||||
|
||||
const handleClickResult = (result) => {
|
||||
if (result.type === 'message' && result.conversation_id) {
|
||||
onNavigate?.('/');
|
||||
// Small delay to let route change before attempting to select conversation
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('studyos:select-conversation', {
|
||||
detail: { id: result.conversation_id },
|
||||
})
|
||||
);
|
||||
}, 50);
|
||||
} else if (result.type === 'pdf') {
|
||||
onNavigate?.('/compare');
|
||||
}
|
||||
clear();
|
||||
};
|
||||
|
||||
const stripHtml = (html) => {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]+>/g, '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||
<div style={{ position: 'relative', marginBottom: 8 }}>
|
||||
<Search
|
||||
size={12}
|
||||
style={{ position: 'absolute', left: 8, top: 7, color: 'var(--text-tertiary)' }}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px 24px 5px 24px',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 11,
|
||||
outline: 'none',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={clear}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
top: 5,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-tertiary)',
|
||||
cursor: 'pointer',
|
||||
padding: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
title="Limpiar"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
zIndex: 20,
|
||||
maxHeight: 320,
|
||||
overflowY: 'auto',
|
||||
backdropFilter: 'blur(24px)',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-tertiary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Buscando...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasResults && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-tertiary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Sin resultados
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messageResults.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--accent-info)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
Mensajes
|
||||
</div>
|
||||
{messageResults.slice(0, 10).map((r) => (
|
||||
<button
|
||||
key={`msg-${r.id}`}
|
||||
onClick={() => handleClickResult(r)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={12} style={{ flexShrink: 0, marginTop: 2, color: 'var(--accent-info)' }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={r.title}
|
||||
>
|
||||
{r.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 2,
|
||||
}}
|
||||
title={stripHtml(r.snippet)}
|
||||
>
|
||||
{stripHtml(r.snippet)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pdfResults.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--accent-info)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
PDFs
|
||||
</div>
|
||||
{pdfResults.slice(0, 10).map((r) => (
|
||||
<button
|
||||
key={`pdf-${r.id}`}
|
||||
onClick={() => handleClickResult(r)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
<FileText size={12} style={{ flexShrink: 0, marginTop: 2, color: 'var(--accent-purple)' }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={r.title}
|
||||
>
|
||||
{r.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 2,
|
||||
}}
|
||||
title={stripHtml(r.snippet)}
|
||||
>
|
||||
{stripHtml(r.snippet)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
Settings, GripVertical, Trash2, FileText, Plus, StickyNote, Search, ChevronDown, ChevronRight,
|
||||
Clock, Map, BookOpen, Layers, BarChart3, FileSpreadsheet,
|
||||
} from 'lucide-react';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import SearchBar from './SearchBar';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
|
||||
function DragOverlayItem({ pdf }) {
|
||||
return (
|
||||
@@ -34,7 +38,10 @@ function SortablePdfItem({ pdf, onDelete }) {
|
||||
export default function Sidebar({
|
||||
collapsed, onToggle, pdfs, progress, conversations, activeConversation, notes,
|
||||
onSelectConversation, onNewConversation, onUploadPdf, onReorderPdf, onDeletePdf,
|
||||
onResetTopic, onNavigateSettings,
|
||||
onResetTopic, onNavigateSettings, onNavigate,
|
||||
hasLoadedConversations = false, hasLoadedPdfs = false,
|
||||
isMobileOpen = false, onClose,
|
||||
searchInputRef, dueCount = 0,
|
||||
}) {
|
||||
const fileInputRef = useRef(null);
|
||||
const [pdfItems, setPdfItems] = useState(() => pdfs);
|
||||
@@ -79,99 +86,153 @@ export default function Sidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="app-sidebar" style={{ width: 260 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 800, letterSpacing: '-0.03em', background: 'linear-gradient(135deg, var(--accent-info), var(--accent-purple))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>StudyOS</h1>
|
||||
<div className="avatar-dot">U</div>
|
||||
</div>
|
||||
<>
|
||||
{isMobileOpen && (
|
||||
<div className="sidebar-backdrop" onClick={onClose} aria-hidden="true" />
|
||||
)}
|
||||
<aside className={`app-sidebar ${isMobileOpen ? 'sidebar--open' : ''}`} style={{ width: 260 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 800, letterSpacing: '-0.03em', background: 'linear-gradient(135deg, var(--accent-info), var(--accent-purple))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>StudyOS</h1>
|
||||
<div className="avatar-dot">U</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px', display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{/* PDFs */}
|
||||
<section>
|
||||
<SectionHeader icon="📄" label="PDFs" expanded={pdfsExpanded} onToggle={() => setPdfsExpanded(!pdfsExpanded)} />
|
||||
{pdfsExpanded && (
|
||||
<>
|
||||
{pdfItems.length > 3 && (
|
||||
<div style={{ position: 'relative', marginBottom: 8 }}>
|
||||
<Search size={12} style={{ position: 'absolute', left: 8, top: 7, color: 'var(--text-tertiary)' }} />
|
||||
<input value={pdfSearch} onChange={e => setPdfSearch(e.target.value)} placeholder="Buscar..."
|
||||
style={{ width: '100%', padding: '5px 8px 5px 24px', background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: 11, outline: 'none', fontFamily: 'var(--font-ui)' }} />
|
||||
</div>
|
||||
)}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={filteredPdfs.map(p => p.id)} strategy={verticalListSortingStrategy}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, maxHeight: pdfSearch ? 200 : 160, overflowY: 'auto' }}>
|
||||
{filteredPdfs.map(pdf => <SortablePdfItem key={pdf.id} pdf={pdf} onDelete={onDeletePdf} />)}
|
||||
{filteredPdfs.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>{pdfSearch ? 'Sin resultados' : 'Sin PDFs'}</span>}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px', display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{/* Search */}
|
||||
<section>
|
||||
<SearchBar inputRef={searchInputRef} onNavigate={onNavigate} />
|
||||
</section>
|
||||
|
||||
{/* PDFs */}
|
||||
<section>
|
||||
<SectionHeader icon="📄" label="PDFs" expanded={pdfsExpanded} onToggle={() => setPdfsExpanded(!pdfsExpanded)} />
|
||||
{pdfsExpanded && (
|
||||
<>
|
||||
{pdfItems.length > 3 && (
|
||||
<div style={{ position: 'relative', marginBottom: 8 }}>
|
||||
<Search size={12} style={{ position: 'absolute', left: 8, top: 7, color: 'var(--text-tertiary)' }} />
|
||||
<input value={pdfSearch} onChange={e => setPdfSearch(e.target.value)} placeholder="Buscar..."
|
||||
style={{ width: '100%', padding: '5px 8px 5px 24px', background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: 11, outline: 'none', fontFamily: 'var(--font-ui)' }} />
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button className="sidebar-upload-btn" onClick={() => fileInputRef.current?.click()}><Plus size={14} /> Subir PDF</button>
|
||||
<input ref={fileInputRef} type="file" accept=".pdf" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Progress */}
|
||||
<section>
|
||||
<SectionHeader icon="📊" label="Progreso" expanded={progressExpanded} onToggle={() => setProgressExpanded(!progressExpanded)} />
|
||||
{progressExpanded && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{progress.map(p => {
|
||||
const color = p.percentage >= 80 ? 'var(--accent-green)' : p.percentage >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)';
|
||||
return (
|
||||
<div key={p.topic}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4, color: 'var(--text-secondary)' }}>
|
||||
<span style={{ fontWeight: 500 }}>{p.topic}</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{p.exercises_correct}/{p.exercises_done}</span>
|
||||
)}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={filteredPdfs.map(p => p.id)} strategy={verticalListSortingStrategy}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, maxHeight: pdfSearch ? 200 : 160, overflowY: 'auto' }}>
|
||||
{filteredPdfs.map(pdf => <SortablePdfItem key={pdf.id} pdf={pdf} onDelete={onDeletePdf} />)}
|
||||
{filteredPdfs.length === 0 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>
|
||||
{pdfSearch ? 'Sin resultados' : 'Sin PDFs'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-progress-bar"><div style={{ width: `${p.percentage}%`, background: color, boxShadow: `0 0 8px ${color}40` }} /></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{pdfs.length === 0 && !hasLoadedPdfs && (
|
||||
<>
|
||||
<Skeleton variant="pdf" />
|
||||
<Skeleton variant="pdf" />
|
||||
</>
|
||||
)}
|
||||
<button className="sidebar-upload-btn" onClick={() => fileInputRef.current?.click()}><Plus size={14} /> Subir PDF</button>
|
||||
<input ref={fileInputRef} type="file" accept=".pdf" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Chats */}
|
||||
<section>
|
||||
<SectionHeader icon="💬" label="Chats" expanded={chatsExpanded} onToggle={() => setChatsExpanded(!chatsExpanded)}
|
||||
extra={<button onClick={onNewConversation} className="icon-btn" style={{ padding: 2 }} title="Nueva"><Plus size={14} /></button>} />
|
||||
{chatsExpanded && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{conversations.map(conv => {
|
||||
const isActive = activeConversation?.id === conv.id;
|
||||
return (
|
||||
<button key={conv.id} onClick={() => onSelectConversation(conv)} className={`sidebar-conv-item ${isActive ? 'active' : ''}`}>
|
||||
<FileText size={14} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{conv.title}</span>
|
||||
{/* Progress */}
|
||||
<section>
|
||||
<SectionHeader icon="📊" label="Progreso" expanded={progressExpanded} onToggle={() => setProgressExpanded(!progressExpanded)} />
|
||||
{progressExpanded && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{progress.map(p => {
|
||||
const color = p.percentage >= 80 ? 'var(--accent-green)' : p.percentage >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)';
|
||||
return (
|
||||
<div key={p.topic}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4, color: 'var(--text-secondary)' }}>
|
||||
<span style={{ fontWeight: 500 }}>{p.topic}</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{p.exercises_correct}/{p.exercises_done}</span>
|
||||
</div>
|
||||
<div className="sidebar-progress-bar"><div style={{ width: `${p.percentage}%`, background: color, boxShadow: `0 0 8px ${color}40` }} /></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Chats */}
|
||||
<section>
|
||||
<SectionHeader icon="💬" label="Chats" expanded={chatsExpanded} onToggle={() => setChatsExpanded(!chatsExpanded)}
|
||||
extra={<button onClick={onNewConversation} className="icon-btn" style={{ padding: 2 }} title="Nueva"><Plus size={14} /></button>} />
|
||||
{chatsExpanded && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{conversations.length === 0 && !hasLoadedConversations && (
|
||||
<>
|
||||
<Skeleton variant="conv" />
|
||||
<Skeleton variant="conv" />
|
||||
<Skeleton variant="conv" />
|
||||
</>
|
||||
)}
|
||||
{conversations.map(conv => {
|
||||
const isActive = activeConversation?.id === conv.id;
|
||||
return (
|
||||
<button key={conv.id} onClick={() => onSelectConversation(conv)} className={`sidebar-conv-item ${isActive ? 'active' : ''}`}>
|
||||
<FileText size={14} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{conv.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
<section>
|
||||
<SectionHeader icon="📝" label="Notas" expanded={notesExpanded} onToggle={() => setNotesExpanded(!notesExpanded)}
|
||||
extra={<button className="icon-btn" style={{ padding: 2 }} title="Nueva" onClick={onNavigateSettings}><Plus size={14} /></button>} />
|
||||
{notesExpanded && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{notes.slice(0, 8).map(note => (
|
||||
<button key={note.id} className="sidebar-note-item">
|
||||
<StickyNote size={12} /> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{note.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{notes.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>Sin notas</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<section>
|
||||
<SectionHeader icon="📝" label="Notas" expanded={notesExpanded} onToggle={() => setNotesExpanded(!notesExpanded)}
|
||||
extra={<button className="icon-btn" style={{ padding: 2 }} title="Nueva" onClick={onNavigateSettings}><Plus size={14} /></button>} />
|
||||
{notesExpanded && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{notes.slice(0, 8).map(note => (
|
||||
<button key={note.id} className="sidebar-note-item">
|
||||
<StickyNote size={12} /> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{note.title}</span>
|
||||
</button>
|
||||
))}
|
||||
{notes.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>Sin notas</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '10px 14px' }}>
|
||||
<button onClick={onNavigateSettings} className="sidebar-nav-btn"><Settings size={16} /><span>Settings</span></button>
|
||||
</div>
|
||||
</aside>
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<button onClick={() => onNavigate?.('/timer')} className="sidebar-nav-btn"><Clock size={16} /><span>Timer</span></button>
|
||||
<button onClick={() => onNavigate?.('/roadmap')} className="sidebar-nav-btn"><Map size={16} /><span>Roadmap</span></button>
|
||||
<button onClick={() => onNavigate?.('/exams')} className="sidebar-nav-btn"><BookOpen size={16} /><span>Exámenes</span></button>
|
||||
<button onClick={() => onNavigate?.('/flashcards')} className="sidebar-nav-btn" style={{ position: 'relative' }}>
|
||||
<Layers size={16} />
|
||||
<span>Flashcards</span>
|
||||
{dueCount > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'var(--accent-coral)',
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
minWidth: 18,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{dueCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => onNavigate?.('/heatmap')} className="sidebar-nav-btn"><BarChart3 size={16} /><span>Heatmap</span></button>
|
||||
<button onClick={() => onNavigate?.('/compare')} className="sidebar-nav-btn"><FileSpreadsheet size={16} /><span>Comparar PDFs</span></button>
|
||||
<button onClick={onNavigateSettings} className="sidebar-nav-btn"><Settings size={16} /><span>Settings</span></button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 4, paddingTop: 8, borderTop: '1px solid var(--border)' }}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
21
client/src/components/Skeleton.jsx
Normal file
21
client/src/components/Skeleton.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export function Skeleton({ variant = 'conv' }) {
|
||||
if (variant === 'pdf') {
|
||||
return (
|
||||
<div className="skeleton-card skeleton-pdf">
|
||||
<div className="skeleton-grip" />
|
||||
<div className="skeleton-lines">
|
||||
<div className="skeleton-line skeleton-line--short" />
|
||||
<div className="skeleton-line skeleton-line--shorter" />
|
||||
</div>
|
||||
<div className="skeleton-shimmer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="skeleton-card skeleton-conv">
|
||||
<div className="skeleton-line skeleton-line--text" />
|
||||
<div className="skeleton-shimmer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
client/src/components/StudyBuddyPanel.css
Normal file
152
client/src/components/StudyBuddyPanel.css
Normal file
@@ -0,0 +1,152 @@
|
||||
.buddy-panel {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.buddy-panel.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.buddy-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.buddy-avatars {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.buddy-avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.buddy-avatar-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.buddy-role-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-info);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.buddy-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.buddy-share-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.buddy-token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.buddy-token code {
|
||||
background: var(--bg-surface);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.buddy-join {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.buddy-join input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.buddy-btn {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--accent-info);
|
||||
color: #fff;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.buddy-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.buddy-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.buddy-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.buddy-msg.user {
|
||||
border-left: 3px solid var(--accent-info);
|
||||
}
|
||||
|
||||
.buddy-msg.assistant {
|
||||
border-left: 3px solid #27ae60;
|
||||
}
|
||||
|
||||
.buddy-msg-role {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.buddy-msg-content {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.buddy-empty {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
78
client/src/components/StudyBuddyPanel.jsx
Normal file
78
client/src/components/StudyBuddyPanel.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import './StudyBuddyPanel.css';
|
||||
|
||||
export default function StudyBuddyPanel({
|
||||
conversation,
|
||||
messages,
|
||||
roleLabel,
|
||||
counterpartLabel,
|
||||
onShare,
|
||||
shareToken,
|
||||
onJoin,
|
||||
loading,
|
||||
}) {
|
||||
const [joinToken, setJoinToken] = useState('');
|
||||
|
||||
if (loading) {
|
||||
return <div className="buddy-panel loading">Cargando modo compañero...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="buddy-panel">
|
||||
<div className="buddy-header">
|
||||
<div className="buddy-avatars">
|
||||
<div className="buddy-avatar self">
|
||||
<span className="buddy-avatar-icon">🎓</span>
|
||||
<span className="buddy-role-badge">{roleLabel}</span>
|
||||
</div>
|
||||
<div className="buddy-avatar other">
|
||||
<span className="buddy-avatar-icon">🎓</span>
|
||||
<span className="buddy-role-badge">{counterpartLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="buddy-title">
|
||||
{conversation?.title || 'Conversación compartida'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="buddy-share-section">
|
||||
{!shareToken && (
|
||||
<button className="buddy-btn" onClick={onShare}>
|
||||
Compartir conversación
|
||||
</button>
|
||||
)}
|
||||
{shareToken && (
|
||||
<div className="buddy-token">
|
||||
<label>Token de compartir:</label>
|
||||
<code>{shareToken}</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="buddy-join">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pegar token para unirse..."
|
||||
value={joinToken}
|
||||
onChange={(e) => setJoinToken(e.target.value)}
|
||||
/>
|
||||
<button className="buddy-btn" onClick={() => onJoin(joinToken)}>
|
||||
Unirse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="buddy-feed">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`buddy-msg ${msg.role}`}>
|
||||
<span className="buddy-msg-role">
|
||||
{msg.role === 'user' ? roleLabel : msg.role === 'assistant' ? 'Tutor' : 'Sistema'}
|
||||
</span>
|
||||
<span className="buddy-msg-content">{msg.content}</span>
|
||||
</div>
|
||||
))}
|
||||
{messages.length === 0 && (
|
||||
<div className="buddy-empty">No hay mensajes aún. ¡Empezá a estudiar con tu compañero!</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
client/src/components/ThemeToggle.jsx
Normal file
41
client/src/components/ThemeToggle.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState('dark');
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('studyos-theme');
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
setTheme(stored);
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
setTheme('light');
|
||||
} else {
|
||||
setTheme('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try {
|
||||
localStorage.setItem('studyos-theme', theme);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggle = () => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
title={theme === 'dark' ? 'Cambiar a claro' : 'Cambiar a oscuro'}
|
||||
className="icon-btn"
|
||||
style={{ padding: 6 }}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
75
client/src/components/Toast.jsx
Normal file
75
client/src/components/Toast.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
const ToastContext = React.createContext(null);
|
||||
|
||||
let toastIdCounter = 0;
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((msg, type, opts = {}) => {
|
||||
const id = ++toastIdCounter;
|
||||
const duration = opts.duration ?? 4000;
|
||||
setToasts((prev) => [...prev, { id, msg, type, duration }]);
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
return id;
|
||||
}, [removeToast]);
|
||||
|
||||
const success = useCallback((msg, opts) => addToast(msg, 'success', opts), [addToast]);
|
||||
const error = useCallback((msg, opts) => addToast(msg, 'error', opts), [addToast]);
|
||||
|
||||
const value = React.useMemo(() => ({ success, error }), [success, error]);
|
||||
|
||||
// Ensure toast root exists
|
||||
const toastRootRef = useRef(null);
|
||||
if (!toastRootRef.current && typeof document !== 'undefined') {
|
||||
let root = document.getElementById('toast-root');
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = 'toast-root';
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
toastRootRef.current = root;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
{toastRootRef.current &&
|
||||
createPortal(
|
||||
<div className="toast-portal">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`toast-card toast-${toast.type}`}
|
||||
style={{ animationDelay: `${toasts.indexOf(toast) * 50}ms` }}
|
||||
>
|
||||
<span className="toast-icon">
|
||||
{toast.type === 'success' ? <Check size={16} /> : <X size={16} />}
|
||||
</span>
|
||||
<span className="toast-msg">{toast.msg}</span>
|
||||
<button className="toast-close" onClick={() => removeToast(toast.id)} title="Dismiss">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
toastRootRef.current
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = React.useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
9
client/src/components/TypingDots.jsx
Normal file
9
client/src/components/TypingDots.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export function TypingDots() {
|
||||
return (
|
||||
<span className="typing-dots">
|
||||
<span className="typing-dot" />
|
||||
<span className="typing-dot" />
|
||||
<span className="typing-dot" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
125
client/src/components/VoiceInput.jsx
Normal file
125
client/src/components/VoiceInput.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Mic, MicOff } from 'lucide-react';
|
||||
|
||||
export default function VoiceInput({ onTranscript, disabled }) {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [supported, setSupported] = useState(false);
|
||||
const recognitionRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (SR) {
|
||||
setSupported(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SR) return;
|
||||
setError(null);
|
||||
const recognition = new SR();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = 'es-ES';
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const results = event.results;
|
||||
if (!results || results.length === 0) return;
|
||||
const last = results[results.length - 1];
|
||||
if (last.isFinal) {
|
||||
const transcript = last[0].transcript;
|
||||
if (onTranscript) onTranscript(transcript);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
if (event.error === 'not-allowed') {
|
||||
setError('Micrófono bloqueado');
|
||||
} else if (event.error === 'no-speech') {
|
||||
// ignore
|
||||
} else {
|
||||
setError('Error de reconocimiento');
|
||||
}
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
recognitionRef.current = recognition;
|
||||
setIsListening(true);
|
||||
} catch (err) {
|
||||
setError('No se pudo iniciar');
|
||||
}
|
||||
}, [onTranscript]);
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isListening) {
|
||||
stopListening();
|
||||
} else {
|
||||
startListening();
|
||||
}
|
||||
}, [isListening, startListening, stopListening]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recognitionRef.current) {
|
||||
try { recognitionRef.current.stop(); } catch {}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
title={isListening ? 'Detener dictado' : 'Dictar mensaje'}
|
||||
style={{
|
||||
color: isListening ? 'var(--accent-coral)' : undefined,
|
||||
animation: isListening ? 'pulse 1.5s ease-in-out infinite' : undefined,
|
||||
}}
|
||||
>
|
||||
{isListening ? <MicOff size={18} /> : <Mic size={18} />}
|
||||
</button>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 6px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'var(--accent-coral)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
client/src/context/ReactionsContext.jsx
Normal file
37
client/src/context/ReactionsContext.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
const ReactionsContext = React.createContext(null);
|
||||
|
||||
export function ReactionsProvider({ children }) {
|
||||
const [reactions, setReactions] = useState({});
|
||||
|
||||
const get = useCallback((msgId) => {
|
||||
return reactions[msgId] ?? null;
|
||||
}, [reactions]);
|
||||
|
||||
const toggle = useCallback((msgId, emoji) => {
|
||||
setReactions((prev) => {
|
||||
const current = prev[msgId];
|
||||
if (current === emoji) {
|
||||
const next = { ...prev };
|
||||
delete next[msgId];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [msgId]: emoji };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(() => ({ get, toggle }), [get, toggle]);
|
||||
|
||||
return (
|
||||
<ReactionsContext.Provider value={value}>
|
||||
{children}
|
||||
</ReactionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useReactions() {
|
||||
const ctx = React.useContext(ReactionsContext);
|
||||
if (!ctx) throw new Error('useReactions must be used within a ReactionsProvider');
|
||||
return ctx;
|
||||
}
|
||||
63
client/src/data/roadmap.json
Normal file
63
client/src/data/roadmap.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"domains": [
|
||||
{
|
||||
"name": "Matemáticas",
|
||||
"color": "#818cf8",
|
||||
"topics": [
|
||||
{ "id": "algebra", "name": "Álgebra" },
|
||||
{ "id": "calculus", "name": "Cálculo" },
|
||||
{ "id": "linear-algebra", "name": "Álgebra Lineal" },
|
||||
{ "id": "statistics", "name": "Estadística" },
|
||||
{ "id": "geometry", "name": "Geometría" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ciencias",
|
||||
"color": "#34d399",
|
||||
"topics": [
|
||||
{ "id": "physics", "name": "Física" },
|
||||
{ "id": "chemistry", "name": "Química" },
|
||||
{ "id": "biology", "name": "Biología" },
|
||||
{ "id": "astronomy", "name": "Astronomía" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Informática",
|
||||
"color": "#f472b6",
|
||||
"topics": [
|
||||
{ "id": "programming", "name": "Programación" },
|
||||
{ "id": "algorithms", "name": "Algoritmos" },
|
||||
{ "id": "databases", "name": "Bases de Datos" },
|
||||
{ "id": "networks", "name": "Redes" },
|
||||
{ "id": "ai", "name": "Inteligencia Artificial" },
|
||||
{ "id": "os", "name": "Sistemas Operativos" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Humanidades",
|
||||
"color": "#fbbf24",
|
||||
"topics": [
|
||||
{ "id": "history", "name": "Historia" },
|
||||
{ "id": "philosophy", "name": "Filosofía" },
|
||||
{ "id": "literature", "name": "Literatura" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "algebra", "to": "calculus" },
|
||||
{ "from": "calculus", "to": "linear-algebra" },
|
||||
{ "from": "calculus", "to": "statistics" },
|
||||
{ "from": "algebra", "to": "geometry" },
|
||||
{ "from": "physics", "to": "calculus" },
|
||||
{ "from": "chemistry", "to": "physics" },
|
||||
{ "from": "biology", "to": "chemistry" },
|
||||
{ "from": "astronomy", "to": "physics" },
|
||||
{ "from": "programming", "to": "algorithms" },
|
||||
{ "from": "algorithms", "to": "ai" },
|
||||
{ "from": "databases", "to": "networks" },
|
||||
{ "from": "os", "to": "networks" },
|
||||
{ "from": "programming", "to": "os" },
|
||||
{ "from": "history", "to": "philosophy" },
|
||||
{ "from": "philosophy", "to": "literature" }
|
||||
]
|
||||
}
|
||||
59
client/src/hooks/__tests__/useChat.test.js
Normal file
59
client/src/hooks/__tests__/useChat.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import useChat from '../useChat';
|
||||
import * as api from '../../lib/api';
|
||||
|
||||
vi.mock('../../lib/api');
|
||||
|
||||
const mockToastError = vi.fn();
|
||||
vi.mock('../../components/Toast', () => ({
|
||||
ToastProvider: ({ children }) => children,
|
||||
useToast: () => ({ error: mockToastError }),
|
||||
}));
|
||||
|
||||
describe('useChat', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.getMessages.mockResolvedValue({ messages: [] });
|
||||
api.postChatStream.mockResolvedValue({
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
api.streamSSE = async function* mockSSE() {
|
||||
yield { type: 'done' };
|
||||
};
|
||||
});
|
||||
|
||||
it('blocks sendMessage when isStreaming and shows toast', async () => {
|
||||
const { result } = renderHook(() => useChat({ conversationId: '1' }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setActiveId('1');
|
||||
});
|
||||
|
||||
// Trigger first message to set isStreaming
|
||||
act(() => {
|
||||
result.current.sendMessage('hello');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
||||
|
||||
// Try sending again while streaming
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('second');
|
||||
});
|
||||
|
||||
// postChatStream called only once
|
||||
expect(api.postChatStream).toHaveBeenCalledTimes(1);
|
||||
// Toast shown
|
||||
expect(mockToastError).toHaveBeenCalledWith('Message already in progress');
|
||||
});
|
||||
});
|
||||
59
client/src/hooks/__tests__/usePdfs.test.js
Normal file
59
client/src/hooks/__tests__/usePdfs.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import usePdfs from '../usePdfs';
|
||||
import * as api from '../../lib/api';
|
||||
|
||||
vi.mock('../../lib/api');
|
||||
|
||||
describe('usePdfs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.getPdfs.mockResolvedValue([
|
||||
{ id: 1, filename: 'a.pdf', sort_order: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('refresh loads PDFs', async () => {
|
||||
const { result } = renderHook(() => usePdfs());
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
expect(result.current.pdfs).toHaveLength(1);
|
||||
expect(api.getPdfs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uploadPdf refreshes list', async () => {
|
||||
const { result } = renderHook(() => usePdfs());
|
||||
const file = new File(['content'], 'test.pdf');
|
||||
api.uploadPdf.mockResolvedValue({});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadPdf(file);
|
||||
});
|
||||
|
||||
expect(api.uploadPdf).toHaveBeenCalledWith(file);
|
||||
expect(api.getPdfs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reorderPdf refreshes list', async () => {
|
||||
const { result } = renderHook(() => usePdfs());
|
||||
api.reorderPdf.mockResolvedValue({});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reorderPdf(1, 2);
|
||||
});
|
||||
|
||||
expect(api.reorderPdf).toHaveBeenCalledWith(1, 2);
|
||||
});
|
||||
|
||||
it('deletePdf refreshes list', async () => {
|
||||
const { result } = renderHook(() => usePdfs());
|
||||
api.deletePdf.mockResolvedValue({});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deletePdf(1);
|
||||
});
|
||||
|
||||
expect(api.deletePdf).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
69
client/src/hooks/__tests__/useProgress.test.js
Normal file
69
client/src/hooks/__tests__/useProgress.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import useProgress from '../useProgress';
|
||||
import * as api from '../../lib/api';
|
||||
|
||||
vi.mock('../../lib/api');
|
||||
|
||||
describe('useProgress', () => {
|
||||
let wsInstances = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.getProgress.mockResolvedValue([]);
|
||||
|
||||
global.WebSocket = vi.fn(function MockWebSocket(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
this.onopen = null;
|
||||
this.onmessage = null;
|
||||
this.onclose = null;
|
||||
this.onerror = null;
|
||||
this.close = vi.fn(() => {
|
||||
this.readyState = 3;
|
||||
if (this.onclose) this.onclose();
|
||||
});
|
||||
this.send = vi.fn();
|
||||
wsInstances.push(this);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wsInstances = [];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('updates state on progress_update message', async () => {
|
||||
api.getProgress.mockResolvedValue([
|
||||
{ topic: 'math', exercises_done: 2, exercises_correct: 1 },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useProgress());
|
||||
|
||||
await waitFor(() => expect(result.current.progress.length).toBe(1));
|
||||
|
||||
const ws = wsInstances[0];
|
||||
act(() => {
|
||||
ws.onmessage({
|
||||
data: JSON.stringify({
|
||||
type: 'progress_update',
|
||||
data: { topic: 'math', exercises_done: 3, exercises_correct: 2 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.progress[0].percentage).toBe(67)
|
||||
);
|
||||
});
|
||||
|
||||
it('closes socket on unmount', async () => {
|
||||
api.getProgress.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useProgress());
|
||||
const ws = wsInstances[0];
|
||||
|
||||
unmount();
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { getMessages, postChatStream, streamSSE, updateProgress } from '../lib/api';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
function extractExerciseJson(text) {
|
||||
const fenceRegex = /```json\s*([\s\S]*?)\s*```/;
|
||||
@@ -29,12 +30,15 @@ function extractExerciseJson(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
export default function useChat({ conversationId, onProgressUpdate, onStudySession, onAutoFork, onDifficultyChanged }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [activeId, setActiveIdState] = useState(conversationId);
|
||||
const [autoForkPrompt, setAutoForkPrompt] = useState(null);
|
||||
const [difficultyChanged, setDifficultyChanged] = useState(null);
|
||||
const abortRef = useRef(null);
|
||||
const activeIdRef = useRef(activeId);
|
||||
const toast = useToast();
|
||||
|
||||
// Sync activeIdRef with current activeId
|
||||
useEffect(() => {
|
||||
@@ -72,7 +76,10 @@ export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (text, pdfIds = [], attachments = []) => {
|
||||
if (isStreaming) return;
|
||||
if (isStreaming) {
|
||||
toast.error('Message already in progress');
|
||||
return;
|
||||
}
|
||||
if (!activeId || !text.trim()) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -87,6 +94,8 @@ export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setIsStreaming(true);
|
||||
setAutoForkPrompt(null);
|
||||
setDifficultyChanged(null);
|
||||
|
||||
const assistantMsg = {
|
||||
id: `temp-assist-${Date.now()}`,
|
||||
@@ -96,6 +105,7 @@ export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
let streamSuccess = false;
|
||||
try {
|
||||
const response = await postChatStream({
|
||||
conversation_id: activeId,
|
||||
@@ -107,21 +117,40 @@ export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
let fullText = '';
|
||||
for await (const event of streamSSE(response)) {
|
||||
if (controller.signal.aborted) break;
|
||||
if (event.type === 'token') {
|
||||
if (event.token !== undefined) {
|
||||
fullText += event.token;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsg.id ? { ...m, content: fullText } : m
|
||||
)
|
||||
);
|
||||
} else if (event.type === 'done') {
|
||||
} else if (event.done) {
|
||||
fullText = event.full_text || fullText;
|
||||
} else if (event.auto_fork_suggest) {
|
||||
setAutoForkPrompt(event.auto_fork_suggest);
|
||||
if (onAutoFork) {
|
||||
onAutoFork({
|
||||
topic: event.auto_fork_suggest.topic,
|
||||
parentId: event.auto_fork_suggest.parent_id,
|
||||
wrongStreak: event.auto_fork_suggest.wrong_streak,
|
||||
});
|
||||
}
|
||||
} else if (event.difficulty_changed) {
|
||||
setDifficultyChanged(event.difficulty_changed);
|
||||
if (onDifficultyChanged) {
|
||||
onDifficultyChanged({
|
||||
level: event.difficulty_changed.level,
|
||||
globalWrongStreak: event.difficulty_changed.global_wrong_streak,
|
||||
});
|
||||
}
|
||||
} else if (event.error) {
|
||||
console.error('[useChat] stream error:', event.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
streamSuccess = true;
|
||||
|
||||
// Exercise JSON parsing after streaming
|
||||
const extracted = extractExerciseJson(fullText);
|
||||
if (extracted && extracted.exercise) {
|
||||
@@ -147,6 +176,16 @@ export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
m.id === assistantMsg.id ? { ...m, content: fullText } : m
|
||||
)
|
||||
);
|
||||
|
||||
// Record study session after successful reply
|
||||
if (onStudySession) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
try {
|
||||
await onStudySession(today, 1);
|
||||
} catch (err) {
|
||||
console.error('[useChat] study session error:', err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('[useChat] send error:', err.message);
|
||||
@@ -163,8 +202,8 @@ export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
}
|
||||
setIsStreaming(false);
|
||||
if (controller.signal.aborted) return;
|
||||
// Refresh messages from server to get persisted IDs only for current conversation
|
||||
if (activeIdRef.current === activeId) {
|
||||
// Only refresh messages from server on success, to avoid overwriting error state
|
||||
if (streamSuccess && activeIdRef.current === activeId) {
|
||||
try {
|
||||
const data = await getMessages(activeId);
|
||||
setMessages(data.messages || []);
|
||||
@@ -174,7 +213,7 @@ export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeId, onProgressUpdate, isStreaming]
|
||||
[activeId, onProgressUpdate, onAutoFork, onDifficultyChanged, onStudySession, isStreaming, toast]
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -183,5 +222,9 @@ export default function useChat({ conversationId, onProgressUpdate }) {
|
||||
activeId,
|
||||
setActiveId,
|
||||
sendMessage,
|
||||
autoForkPrompt,
|
||||
setAutoForkPrompt,
|
||||
difficultyChanged,
|
||||
setDifficultyChanged,
|
||||
};
|
||||
}
|
||||
|
||||
119
client/src/hooks/useExam.js
Normal file
119
client/src/hooks/useExam.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { submitExam } from '../lib/api';
|
||||
|
||||
export default function useExam(exam) {
|
||||
const [currentQuestion, setCurrentQuestion] = useState(0);
|
||||
const [answers, setAnswers] = useState([]);
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||
const [status, setStatus] = useState('idle'); // idle, running, submitted, expired
|
||||
const [result, setResult] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
const answersRef = useRef(answers);
|
||||
const examRef = useRef(exam);
|
||||
|
||||
useEffect(() => { answersRef.current = answers; }, [answers]);
|
||||
useEffect(() => { examRef.current = exam; }, [exam]);
|
||||
|
||||
// Initialize when exam data arrives
|
||||
useEffect(() => {
|
||||
if (!exam || !exam.questions) {
|
||||
setStatus('idle');
|
||||
setRemainingSeconds(0);
|
||||
return;
|
||||
}
|
||||
setAnswers(new Array(exam.questions.length).fill(null));
|
||||
setCurrentQuestion(0);
|
||||
setResult(null);
|
||||
|
||||
const startedAt = new Date(exam.started_at).getTime();
|
||||
const durationMs = (exam.duration_seconds || 0) * 1000;
|
||||
const now = Date.now();
|
||||
const elapsed = now - startedAt;
|
||||
const remaining = Math.max(0, Math.ceil((durationMs - elapsed) / 1000));
|
||||
|
||||
if (remaining <= 0) {
|
||||
setStatus('expired');
|
||||
setRemainingSeconds(0);
|
||||
} else {
|
||||
setStatus('running');
|
||||
setRemainingSeconds(remaining);
|
||||
}
|
||||
}, [exam]);
|
||||
|
||||
const handleAutoSubmit = useCallback(async () => {
|
||||
const currentExam = examRef.current;
|
||||
const currentAnswers = answersRef.current;
|
||||
if (!currentExam?.id) return;
|
||||
try {
|
||||
const res = await submitExam(currentExam.id, currentAnswers);
|
||||
setResult(res);
|
||||
setStatus('submitted');
|
||||
} catch (err) {
|
||||
console.error('[useExam] auto-submit error:', err.message);
|
||||
setStatus('expired');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (status !== 'running') return;
|
||||
timerRef.current = setInterval(() => {
|
||||
setRemainingSeconds((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current);
|
||||
// Auto-submit at 0
|
||||
handleAutoSubmit();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [status, handleAutoSubmit]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!exam || !exam.id || status !== 'running') return;
|
||||
clearInterval(timerRef.current);
|
||||
try {
|
||||
const res = await submitExam(exam.id, answers);
|
||||
setResult(res);
|
||||
setStatus('submitted');
|
||||
} catch (err) {
|
||||
console.error('[useExam] submit error:', err.message);
|
||||
setStatus('expired');
|
||||
}
|
||||
}, [exam, answers, status]);
|
||||
|
||||
const setAnswer = useCallback((questionIndex, answer) => {
|
||||
setAnswers((prev) => {
|
||||
const next = [...prev];
|
||||
next[questionIndex] = answer;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentQuestion((q) => Math.min(q + 1, (exam?.questions?.length || 1) - 1));
|
||||
}, [exam]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentQuestion((q) => Math.max(q - 1, 0));
|
||||
}, []);
|
||||
|
||||
const percentTimeRemaining = exam && exam.duration_seconds
|
||||
? Math.min(100, Math.max(0, (remainingSeconds / exam.duration_seconds) * 100))
|
||||
: 100;
|
||||
|
||||
return {
|
||||
currentQuestion,
|
||||
answers,
|
||||
remainingSeconds,
|
||||
status,
|
||||
result,
|
||||
setAnswer,
|
||||
goNext,
|
||||
goPrev,
|
||||
handleSubmit,
|
||||
percentTimeRemaining,
|
||||
};
|
||||
}
|
||||
79
client/src/hooks/useExams.js
Normal file
79
client/src/hooks/useExams.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
getExams,
|
||||
createExam as apiCreateExam,
|
||||
updateExam as apiUpdateExam,
|
||||
deleteExam as apiDeleteExam,
|
||||
} from '../lib/api';
|
||||
|
||||
export default function useExams() {
|
||||
const [exams, setExams] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getExams();
|
||||
setExams(rows);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[useExams] refresh error:', err.message);
|
||||
setError(err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (body) => {
|
||||
try {
|
||||
const row = await apiCreateExam(body);
|
||||
setExams((prev) => [row, ...prev]);
|
||||
setError(null);
|
||||
return row;
|
||||
} catch (err) {
|
||||
console.error('[useExams] create error:', err.message);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (id, body) => {
|
||||
try {
|
||||
const row = await apiUpdateExam(id, body);
|
||||
setExams((prev) => prev.map((e) => (e.id === id ? row : e)));
|
||||
setError(null);
|
||||
return row;
|
||||
} catch (err) {
|
||||
console.error('[useExams] update error:', err.message);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const remove = useCallback(async (id) => {
|
||||
try {
|
||||
await apiDeleteExam(id);
|
||||
setExams((prev) => prev.filter((e) => e.id !== id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[useExams] delete error:', err.message);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filterByTopic = useCallback(
|
||||
(topic) => {
|
||||
if (!topic) return exams;
|
||||
return exams.filter((e) => e.topics && e.topics.includes(topic));
|
||||
},
|
||||
[exams]
|
||||
);
|
||||
|
||||
return {
|
||||
exams,
|
||||
error,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
filterByTopic,
|
||||
};
|
||||
}
|
||||
162
client/src/hooks/useFlashcards.js
Normal file
162
client/src/hooks/useFlashcards.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
generateFlashcards as apiGenerateFlashcards,
|
||||
getFlashcards,
|
||||
updateFlashcard as apiUpdateFlashcard,
|
||||
deleteFlashcard as apiDeleteFlashcard,
|
||||
getFlashcardReviews,
|
||||
reviewFlashcard as apiReviewFlashcard,
|
||||
streamSSE,
|
||||
} from '../lib/api';
|
||||
|
||||
export default function useFlashcards() {
|
||||
const [cards, setCards] = useState([]);
|
||||
const [queue, setQueue] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streamEvents, setStreamEvents] = useState([]);
|
||||
const [dueCount, setDueCount] = useState(0);
|
||||
const [dueCards, setDueCards] = useState([]);
|
||||
|
||||
const refresh = useCallback(async (params = {}) => {
|
||||
try {
|
||||
const rows = await getFlashcards(params);
|
||||
setCards(rows);
|
||||
} catch (err) {
|
||||
console.error('[useFlashcards] refresh error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshDue = useCallback(async () => {
|
||||
try {
|
||||
const data = await getFlashcardReviews();
|
||||
setDueCount(data?.count || 0);
|
||||
setDueCards(data?.cards || []);
|
||||
} catch (err) {
|
||||
console.error('[useFlashcards] refreshDue error:', err.message);
|
||||
setDueCount(0);
|
||||
setDueCards([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load due count on mount
|
||||
useEffect(() => {
|
||||
refreshDue();
|
||||
}, [refreshDue]);
|
||||
|
||||
const generate = useCallback(async (body) => {
|
||||
setLoading(true);
|
||||
setStreamEvents([]);
|
||||
try {
|
||||
const response = await apiGenerateFlashcards(body);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
setStreamEvents((prev) => [...prev, event]);
|
||||
if (event.type === 'card') {
|
||||
setCards((prev) => [event.card, ...prev]);
|
||||
setQueue((prev) => [...prev, event.card]);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed SSE lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useFlashcards] generate error:', err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNext = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getFlashcards({ seen: 0 });
|
||||
setQueue(rows);
|
||||
} catch (err) {
|
||||
console.error('[useFlashcards] fetchNext error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reveal = useCallback((id) => {
|
||||
setQueue((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, _revealed: true } : c))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const markSeen = useCallback(async (id) => {
|
||||
try {
|
||||
const row = await apiUpdateFlashcard(id, { seen: true });
|
||||
setQueue((prev) => prev.filter((c) => c.id !== id));
|
||||
setCards((prev) => prev.map((c) => (c.id === id ? row : c)));
|
||||
} catch (err) {
|
||||
console.error('[useFlashcards] markSeen error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (id, body) => {
|
||||
try {
|
||||
const row = await apiUpdateFlashcard(id, body);
|
||||
setCards((prev) => prev.map((c) => (c.id === id ? row : c)));
|
||||
setQueue((prev) => prev.map((c) => (c.id === id ? row : c)));
|
||||
return row;
|
||||
} catch (err) {
|
||||
console.error('[useFlashcards] update error:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const remove = useCallback(async (id) => {
|
||||
try {
|
||||
await apiDeleteFlashcard(id);
|
||||
setCards((prev) => prev.filter((c) => c.id !== id));
|
||||
setQueue((prev) => prev.filter((c) => c.id !== id));
|
||||
} catch (err) {
|
||||
console.error('[useFlashcards] delete error:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const review = useCallback(async (id, quality) => {
|
||||
try {
|
||||
// Server authoritative write
|
||||
const result = await apiReviewFlashcard(id, quality);
|
||||
|
||||
// Refresh due list
|
||||
await refreshDue();
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[useFlashcards] review error:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}, [dueCards, refreshDue]);
|
||||
|
||||
return {
|
||||
cards,
|
||||
queue,
|
||||
loading,
|
||||
generate,
|
||||
fetchNext,
|
||||
reveal,
|
||||
markSeen,
|
||||
update,
|
||||
remove,
|
||||
streamEvents,
|
||||
dueCount,
|
||||
dueCards,
|
||||
review,
|
||||
refreshDue,
|
||||
};
|
||||
}
|
||||
18
client/src/hooks/useMediaQuery.js
Normal file
18
client/src/hooks/useMediaQuery.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useMediaQuery(query) {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const handler = (e) => setMatches(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
setMatches(mql.matches);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { getProgress, updateProgress, resetProgressTopic } from '../lib/api';
|
||||
|
||||
function calcPercentage(row) {
|
||||
@@ -9,6 +9,9 @@ function calcPercentage(row) {
|
||||
export default function useProgress() {
|
||||
const [progress, setProgress] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const wsRef = useRef(null);
|
||||
const reconnectDelayRef = useRef(1000);
|
||||
const reconnectTimerRef = useRef(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -28,6 +31,62 @@ export default function useProgress() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
||||
const connect = () => {
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectDelayRef.current = 1000;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'progress_update' && msg.data) {
|
||||
setProgress((prev) => {
|
||||
const row = msg.data;
|
||||
const enriched = { ...row, percentage: calcPercentage(row) };
|
||||
const idx = prev.findIndex((p) => p.topic === row.topic);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = enriched;
|
||||
return next;
|
||||
}
|
||||
return [...prev, enriched];
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
const delay = Math.min(reconnectDelayRef.current, 30000);
|
||||
reconnectDelayRef.current *= 2;
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const updateExercise = useCallback(
|
||||
|
||||
44
client/src/hooks/useSearch.js
Normal file
44
client/src/hooks/useSearch.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { searchApi } from '../lib/api';
|
||||
|
||||
export default function useSearch() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const data = await searchApi(trimmed);
|
||||
setResults(data || []);
|
||||
} catch (err) {
|
||||
console.error('[useSearch] error:', err.message);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return { query, setQuery, results, loading, clear };
|
||||
}
|
||||
95
client/src/hooks/useStudyBuddy.js
Normal file
95
client/src/hooks/useStudyBuddy.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { getSharedConversation, getMessages } from '../lib/api';
|
||||
|
||||
export default function useStudyBuddy(token) {
|
||||
const [conversation, setConversation] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [roleLabel, setRoleLabel] = useState('compañero');
|
||||
const [counterpartLabel, setCounterpartLabel] = useState('compañero');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const wsRef = useRef(null);
|
||||
const convIdRef = useRef(null);
|
||||
|
||||
// Load shared conversation from token
|
||||
const loadShared = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getSharedConversation(token);
|
||||
setConversation(data.conversation);
|
||||
setMessages(data.messages || []);
|
||||
setRoleLabel(data.role_label || 'compañero');
|
||||
setCounterpartLabel(data.counterpart_label || 'compañero');
|
||||
convIdRef.current = data.conversation?.id ?? null;
|
||||
} catch (err) {
|
||||
console.error('[useStudyBuddy] load error:', err.message);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadShared();
|
||||
}, [loadShared]);
|
||||
|
||||
// WebSocket connection for real-time updates
|
||||
useEffect(() => {
|
||||
if (!conversation?.id) return;
|
||||
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[useStudyBuddy] WS connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.type === 'buddy_msg' && payload.conv_id === conversation.id) {
|
||||
// Refresh messages
|
||||
refreshMessages();
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('[useStudyBuddy] WS error:', err);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[useStudyBuddy] WS disconnected');
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [conversation?.id]);
|
||||
|
||||
const refreshMessages = useCallback(async () => {
|
||||
const convId = convIdRef.current;
|
||||
if (!convId) return;
|
||||
try {
|
||||
const data = await getMessages(convId);
|
||||
setMessages(data.messages || []);
|
||||
} catch (err) {
|
||||
console.error('[useStudyBuddy] refresh error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
messages,
|
||||
roleLabel,
|
||||
counterpartLabel,
|
||||
loading,
|
||||
error,
|
||||
refreshMessages,
|
||||
};
|
||||
}
|
||||
31
client/src/hooks/useStudySessions.js
Normal file
31
client/src/hooks/useStudySessions.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { recordStudySession as apiRecordSession, getHeatmap } from '../lib/api';
|
||||
|
||||
export default function useStudySessions() {
|
||||
const [heatmapData, setHeatmapData] = useState([]);
|
||||
|
||||
const recordSession = useCallback(async (date, minutes, topic) => {
|
||||
try {
|
||||
await apiRecordSession(date, minutes, topic);
|
||||
} catch (err) {
|
||||
console.error('[useStudySessions] record error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const heatmap = useCallback(async (days = 365) => {
|
||||
try {
|
||||
const rows = await getHeatmap(days);
|
||||
setHeatmapData(rows);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
console.error('[useStudySessions] heatmap error:', err.message);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recordSession,
|
||||
heatmap,
|
||||
heatmapData,
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,32 @@
|
||||
html, body, #root { height: 100%; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-base: #f8f9fb;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #f0f2f7;
|
||||
--bg-glass: rgba(0,0,0,0.03);
|
||||
--border: #e2e5ec;
|
||||
--border-glow: #d0d4e0;
|
||||
--text-primary: #1a1c23;
|
||||
--text-secondary: #5b5f6e;
|
||||
--text-tertiary: #8b8fa0;
|
||||
--accent-green: #10b981;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-coral: #ef4444;
|
||||
--accent-info: #6366f1;
|
||||
--accent-purple: #8b5cf6;
|
||||
--accent-pink: #ec4899;
|
||||
--accent-cyan: #06b6d4;
|
||||
--accent-orange: #f97316;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
|
||||
--shadow-md: 0 4px 14px rgba(0,0,0,0.08);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.10);
|
||||
--shadow-glow-green: 0 0 20px rgba(16,185,129,0.12);
|
||||
--shadow-glow-purple: 0 0 24px rgba(139,92,246,0.10);
|
||||
--shadow-glow-blue: 0 0 20px rgba(99,102,241,0.10);
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #0f1117 0%, #13152a 30%, #0f172a 60%, #0f1117 100%);
|
||||
color: var(--text-primary);
|
||||
@@ -66,3 +92,84 @@ code, pre { font-family: var(--font-mono); }
|
||||
@keyframes scaleIn { from{opacity:0;transform:scale(0.96)} to{opacity:1;transform:scale(1)} }
|
||||
@keyframes glowPulse { 0%,100%{box-shadow:0 0 8px rgba(129,140,248,0.2)} 50%{box-shadow:0 0 20px rgba(129,140,248,0.4)} }
|
||||
@keyframes shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} }
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.9); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
/* Toast portal */
|
||||
#toast-root { position: fixed; top: 16px; right: 16px; z-index: 200; pointer-events: none; }
|
||||
.toast-portal { display: flex; flex-direction: column; gap: 8px; pointer-events: auto; }
|
||||
.toast-card {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
color: var(--text-primary); font-size: 13px; min-width: 220px; max-width: 360px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fade-in 0.3s ease;
|
||||
}
|
||||
.toast-card.toast-success { background: linear-gradient(135deg, rgba(52,211,153,0.12), rgba(52,211,153,0.06)); border-color: rgba(52,211,153,0.35); }
|
||||
.toast-card.toast-error { background: linear-gradient(135deg, rgba(248,113,113,0.12), rgba(248,113,113,0.06)); border-color: rgba(248,113,113,0.35); }
|
||||
.toast-icon { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.toast-msg { flex: 1; line-height: 1.4; }
|
||||
.toast-close { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: var(--radius-sm); transition: color var(--transition-fast); }
|
||||
.toast-close:hover { color: var(--text-primary); }
|
||||
|
||||
/* Typing dots */
|
||||
.typing-dots { display: inline-flex; align-items: center; gap: 4px; padding: 8px 12px; }
|
||||
.typing-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent-info);
|
||||
animation: typing-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.typing-dot:nth-child(1) { animation-delay: 0ms; }
|
||||
.typing-dot:nth-child(2) { animation-delay: 150ms; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 300ms; }
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton-card {
|
||||
position: relative; overflow: hidden;
|
||||
background: var(--bg-elevated); border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border); margin-bottom: 6px;
|
||||
}
|
||||
.skeleton-shimmer {
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.6s infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.skeleton-conv { padding: 10px 14px; }
|
||||
.skeleton-pdf { padding: 8px 10px; display: flex; align-items: center; gap: 8px; }
|
||||
.skeleton-grip { width: 14px; height: 14px; border-radius: 2px; background: var(--border); flex-shrink: 0; }
|
||||
.skeleton-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; }
|
||||
.skeleton-line { height: 10px; border-radius: var(--radius-pill); background: var(--border); }
|
||||
.skeleton-line--text { width: 75%; height: 14px; }
|
||||
.skeleton-line--short { width: 60%; }
|
||||
.skeleton-line--shorter { width: 40%; }
|
||||
|
||||
/* Mobile sidebar */
|
||||
@media (max-width: 767px) {
|
||||
.app-sidebar {
|
||||
position: fixed !important; top: 0; left: 0; height: 100vh;
|
||||
transform: translateX(-100%);
|
||||
z-index: 30; transition: transform var(--transition-slow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
client/src/lib/__tests__/api.test.js
Normal file
71
client/src/lib/__tests__/api.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { backupDatabase, restoreDatabase } from '../api';
|
||||
|
||||
describe('api', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
describe('backupDatabase', () => {
|
||||
it('fetches backup endpoint and triggers download', async () => {
|
||||
const blob = new Blob(['db-content']);
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(blob),
|
||||
});
|
||||
|
||||
const createObjectURL = vi.fn(() => 'blob:test');
|
||||
const revokeObjectURL = vi.fn();
|
||||
URL.createObjectURL = createObjectURL;
|
||||
URL.revokeObjectURL = revokeObjectURL;
|
||||
|
||||
const clickSpy = vi.fn();
|
||||
const anchorSpy = vi.spyOn(document, 'createElement').mockReturnValue({
|
||||
href: '',
|
||||
download: '',
|
||||
click: clickSpy,
|
||||
});
|
||||
|
||||
await backupDatabase();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/config/backup');
|
||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test');
|
||||
|
||||
anchorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
global.fetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
await expect(backupDatabase()).rejects.toThrow('Backup failed: 404');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreDatabase', () => {
|
||||
it('posts file as FormData', async () => {
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ ok: true }),
|
||||
});
|
||||
|
||||
const file = new File(['db'], 'studyos.db');
|
||||
await restoreDatabase(file);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/config/restore',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(FormData),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
global.fetch.mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve('bad') });
|
||||
const file = new File(['db'], 'studyos.db');
|
||||
await expect(restoreDatabase(file)).rejects.toThrow('Restore failed: 400 bad');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,19 @@ export async function createConversation(body) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversation(id, body) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/conversations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to update conversation: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMessages(convId) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/conversations/${convId}/messages`);
|
||||
@@ -250,6 +263,128 @@ export async function testModel(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Exams
|
||||
export async function getExams(topic) {
|
||||
try {
|
||||
const url = topic ? `${API_BASE}/exams?topic=${encodeURIComponent(topic)}` : `${API_BASE}/exams`;
|
||||
const res = await fetch(url);
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch exams: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExam(body) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/exams`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to create exam: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExam(id, body) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/exams/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to update exam: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteExam(id) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/exams/${id}`, { method: 'DELETE' });
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to delete exam: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Flashcards
|
||||
export async function generateFlashcards(body) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/flashcards/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to generate flashcards: ${res.status} ${text}`);
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to generate flashcards: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFlashcards(params = {}) {
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.seen !== undefined) qs.append('seen', params.seen);
|
||||
if (params.topic) qs.append('topic', params.topic);
|
||||
const url = qs.toString() ? `${API_BASE}/flashcards?${qs.toString()}` : `${API_BASE}/flashcards`;
|
||||
const res = await fetch(url);
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch flashcards: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFlashcard(id, body) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/flashcards/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to update flashcard: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFlashcard(id) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/flashcards/${id}`, { method: 'DELETE' });
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to delete flashcard: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Study sessions
|
||||
export async function recordStudySession(date, minutes, topic) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/progress/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date, minutes, topic }),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to record study session: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHeatmap(days = 365) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/progress/heatmap?days=${days}`);
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch heatmap: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamSSE(response) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -297,6 +432,40 @@ export async function* streamSSE(response) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function backupDatabase() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/config/backup`);
|
||||
if (!res.ok) throw new Error(`Backup failed: ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'studyos.db';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to backup database: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreDatabase(file) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch(`${API_BASE}/config/restore`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Restore failed: ${res.status} ${text}`);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to restore database: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postChatStream(body, signal) {
|
||||
const res = await fetch(`${API_BASE}/chat/stream`, {
|
||||
method: 'POST',
|
||||
@@ -310,3 +479,150 @@ export async function postChatStream(body, signal) {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Search
|
||||
export async function searchApi(q) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/search?q=${encodeURIComponent(q)}`);
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to search: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Flashcard reviews
|
||||
export async function getFlashcardReviews() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/flashcards/reviews/due`);
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch flashcard reviews: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewFlashcard(id, quality) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/flashcards/${id}/review`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quality }),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to review flashcard: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Exam PDF
|
||||
export async function downloadExamPdf() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/progress/exam/pdf`);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`PDF download failed: ${res.status} ${text}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `examen-simulado-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to download exam PDF: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Exam generation + submission
|
||||
export async function generateExam(body) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/exams/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to generate exam: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExam(id) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/exams/${id}`);
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch exam: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startExam(id) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/exams/${id}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to start exam: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitExam(id, answers) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/exams/${id}/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to submit exam: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Study buddy share
|
||||
export async function shareConversation(id, roleLabel) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/conversations/${id}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role_label: roleLabel }),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to share conversation: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinConversation(shareToken, userName) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/conversations/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ share_token: shareToken, user_name: userName }),
|
||||
});
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to join conversation: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSharedConversation(token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/conversations/shared/${token}`);
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to load shared conversation: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// PDF embeddings (for debugging)
|
||||
export async function getPdfEmbeddings(pdfId) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/pdfs/${pdfId}/embeddings`);
|
||||
return await handleResponse(res);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch PDF embeddings: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
21
client/src/lib/sm2.js
Normal file
21
client/src/lib/sm2.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export function sm2(prev, quality) {
|
||||
let { ease_factor: e, interval_days: i, repetitions: r } = prev;
|
||||
if (quality < 3) {
|
||||
r = 0;
|
||||
i = 1;
|
||||
} else {
|
||||
if (r === 0) i = 1;
|
||||
else if (r === 1) i = 6;
|
||||
else i = Math.round(i * e);
|
||||
r += 1;
|
||||
}
|
||||
e = Math.max(1.3, e + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
|
||||
const next = new Date();
|
||||
next.setDate(next.getDate() + i);
|
||||
return {
|
||||
ease_factor: +e.toFixed(2),
|
||||
interval_days: i,
|
||||
repetitions: r,
|
||||
next_review: next.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
20
client/src/lib/useOnlineStatus.js
Normal file
20
client/src/lib/useOnlineStatus.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
@@ -10,3 +10,19 @@ root.render(
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Register PWA service worker in production
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
import('virtual:pwa-register').then(({ registerSW }) => {
|
||||
registerSW({ immediate: true });
|
||||
}).catch(() => {
|
||||
// silent fail if virtual:pwa-register is unavailable
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
document.body.setAttribute('data-offline', 'true');
|
||||
});
|
||||
window.addEventListener('online', () => {
|
||||
document.body.removeAttribute('data-offline');
|
||||
});
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
deletePdf,
|
||||
getProgress,
|
||||
resetProgressTopic,
|
||||
backupDatabase,
|
||||
restoreDatabase,
|
||||
downloadExamPdf,
|
||||
} from '../lib/api';
|
||||
|
||||
function ToggleSwitch({ checked, onChange }) {
|
||||
@@ -84,6 +87,8 @@ export default function Settings() {
|
||||
|
||||
// Progress tab
|
||||
const [progress, setProgress] = useState([]);
|
||||
const [restoreFile, setRestoreFile] = useState(null);
|
||||
const [restoreLoading, setRestoreLoading] = useState(false);
|
||||
|
||||
const refreshModels = useCallback(async () => {
|
||||
try {
|
||||
@@ -268,6 +273,27 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackup = async () => {
|
||||
try {
|
||||
await backupDatabase();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async () => {
|
||||
if (!restoreFile) return;
|
||||
if (!window.confirm('¿Restaurar base de datos? Se reemplazará el estado actual.')) return;
|
||||
setRestoreLoading(true);
|
||||
try {
|
||||
await restoreDatabase(restoreFile);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
setRestoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const progressChartData = progress.map((p) => ({
|
||||
name: p.topic,
|
||||
pct: p.percentage,
|
||||
@@ -311,6 +337,18 @@ export default function Settings() {
|
||||
>
|
||||
Progreso
|
||||
</button>
|
||||
<button
|
||||
className={`settings-tab ${activeTab === 'backup' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('backup')}
|
||||
>
|
||||
Backup
|
||||
</button>
|
||||
<button
|
||||
className={`settings-tab ${activeTab === 'datos' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('datos')}
|
||||
>
|
||||
Datos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab 1: Modelos */}
|
||||
@@ -635,6 +673,70 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 4: Backup */}
|
||||
{activeTab === 'backup' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Base de datos
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-secondary" onClick={handleDownloadBackup}>
|
||||
Download Backup
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".db"
|
||||
onChange={(e) => setRestoreFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleRestoreBackup}
|
||||
disabled={!restoreFile || restoreLoading}
|
||||
>
|
||||
{restoreLoading ? (
|
||||
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
|
||||
) : null}
|
||||
Restore Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 5: Datos */}
|
||||
{activeTab === 'datos' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Exportar examen simulado
|
||||
</h2>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
Genera un PDF con los 20 temas más recientes de tu progreso.
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={downloadExamPdf}>
|
||||
Exportar examen simulado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Modal */}
|
||||
{modelModalOpen && (
|
||||
<Modal
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: { enabled: false },
|
||||
manifest: {
|
||||
name: 'StudyOS',
|
||||
short_name: 'StudyOS',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#000000',
|
||||
icons: [
|
||||
{ src: '/pwa-icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/pwa-icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
9
client/vitest.config.js
Normal file
9
client/vitest.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.js'],
|
||||
},
|
||||
});
|
||||
1
client/vitest.setup.js
Normal file
1
client/vitest.setup.js
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
127
server/db.js
127
server/db.js
@@ -21,6 +21,11 @@ function flatParams(params) {
|
||||
return params;
|
||||
}
|
||||
|
||||
function safeAddColumn(table, col, def) {
|
||||
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); }
|
||||
catch (e) { /* column exists */ }
|
||||
}
|
||||
|
||||
function createWrapper(sqlDb) {
|
||||
return {
|
||||
prepare(sql) {
|
||||
@@ -72,6 +77,8 @@ function createWrapper(sqlDb) {
|
||||
};
|
||||
}
|
||||
|
||||
// Placeholder wrapper — methods are replaced by initDB() after sql.js loads.
|
||||
// This allows synchronous require('db') in route modules before async init.
|
||||
const db = createWrapper(null);
|
||||
|
||||
async function initDB() {
|
||||
@@ -159,8 +166,85 @@ async function initDB() {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
topics TEXT NOT NULL DEFAULT '[]',
|
||||
taken_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flashcards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
pdf_id INTEGER REFERENCES pdfs(id) ON DELETE SET NULL,
|
||||
message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
||||
seen INTEGER NOT NULL DEFAULT 0,
|
||||
topic TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_date TEXT NOT NULL,
|
||||
minutes INTEGER NOT NULL DEFAULT 0,
|
||||
topic TEXT,
|
||||
UNIQUE(session_date, topic)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topic_relationships (
|
||||
from_topic TEXT NOT NULL,
|
||||
to_topic TEXT NOT NULL,
|
||||
domain TEXT,
|
||||
PRIMARY KEY(from_topic, to_topic)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flashcard_reviews (
|
||||
flashcard_id INTEGER PRIMARY KEY REFERENCES flashcards(id) ON DELETE CASCADE,
|
||||
ease_factor REAL NOT NULL DEFAULT 2.5,
|
||||
interval_days INTEGER NOT NULL DEFAULT 1,
|
||||
repetitions INTEGER NOT NULL DEFAULT 0,
|
||||
next_review TEXT NOT NULL DEFAULT (date('now')),
|
||||
last_review TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Try to create FTS5 virtual tables and sync triggers
|
||||
let fts5Available = false;
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(content, content='messages', content_rowid='id');
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS pdfs_fts USING fts5(content_markdown, content='pdfs', content_rowid='id');
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
||||
UPDATE messages_fts SET content = new.content WHERE rowid = new.id;
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS pdfs_ai AFTER INSERT ON pdfs BEGIN
|
||||
INSERT INTO pdfs_fts(rowid, content_markdown) VALUES (new.id, new.content_markdown);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS pdfs_au AFTER UPDATE ON pdfs BEGIN
|
||||
UPDATE pdfs_fts SET content_markdown = new.content_markdown WHERE rowid = new.id;
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS pdfs_ad AFTER DELETE ON pdfs BEGIN
|
||||
DELETE FROM pdfs_fts WHERE rowid = old.id;
|
||||
END;
|
||||
`);
|
||||
fts5Available = true;
|
||||
} catch (err) {
|
||||
console.warn('[db] FTS5 setup failed:', err.message, '— search will use LIKE fallback');
|
||||
}
|
||||
db._fts5Available = fts5Available;
|
||||
|
||||
const modelCount = db.prepare('SELECT COUNT(*) as count FROM models').get();
|
||||
if (!modelCount || modelCount.count === 0) {
|
||||
db.prepare(`
|
||||
@@ -174,6 +258,49 @@ async function initDB() {
|
||||
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('vlm_endpoint', 'http://localhost:8080/vlm');
|
||||
}
|
||||
|
||||
// Additive schema changes for ai-advanced-batch4
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS embeddings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pdf_id INTEGER NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
vector BLOB NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (pdf_id) REFERENCES pdfs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_embeddings_pdf ON embeddings(pdf_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_conversations (
|
||||
token TEXT PRIMARY KEY,
|
||||
conv_id INTEGER NOT NULL,
|
||||
role_label TEXT NOT NULL DEFAULT 'compañero',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conv_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
user_name TEXT NOT NULL,
|
||||
joined_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
safeAddColumn('progress', 'wrong_streak', 'INTEGER DEFAULT 0');
|
||||
safeAddColumn('progress', 'global_wrong_streak', 'INTEGER DEFAULT 0');
|
||||
safeAddColumn('progress', 'difficulty_level', 'TEXT DEFAULT \'normal\'');
|
||||
|
||||
safeAddColumn('conversations', 'buddy_meta', 'TEXT');
|
||||
|
||||
safeAddColumn('exams', 'questions', 'TEXT');
|
||||
safeAddColumn('exams', 'answers', 'TEXT');
|
||||
safeAddColumn('exams', 'duration_seconds', 'INTEGER');
|
||||
safeAddColumn('exams', 'started_at', 'TEXT');
|
||||
safeAddColumn('exams', 'status', 'TEXT DEFAULT \'pending\'');
|
||||
safeAddColumn('exams', 'conversation_id', 'INTEGER');
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
|
||||
const db = require('./db');
|
||||
const { setWss, broadcast, broadcastBuddy } = require('./lib/broadcast');
|
||||
const embeddings = require('./lib/embeddings');
|
||||
const pdfRoutes = require('./routes/pdfs');
|
||||
const conversationRoutes = require('./routes/conversations');
|
||||
const chatRoutes = require('./routes/chat');
|
||||
@@ -12,6 +15,9 @@ const progressRoutes = require('./routes/progress');
|
||||
const notesRoutes = require('./routes/notes');
|
||||
const modelRoutes = require('./routes/models');
|
||||
const configRoutes = require('./routes/config');
|
||||
const examRoutes = require('./routes/exams');
|
||||
const flashcardRoutes = require('./routes/flashcards');
|
||||
const searchRoutes = require('./routes/search');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
@@ -33,9 +39,15 @@ app.use('/api/progress', progressRoutes);
|
||||
app.use('/api/notes', notesRoutes);
|
||||
app.use('/api/models', modelRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
app.use('/api/exams', examRoutes);
|
||||
app.use('/api/flashcards', flashcardRoutes);
|
||||
app.use('/api/search', searchRoutes);
|
||||
|
||||
// Serve React build in production
|
||||
const clientDist = path.resolve(__dirname, '..', 'client', 'dist');
|
||||
if (!fs.existsSync(clientDist)) {
|
||||
console.warn('[index] client/dist not found — SPA fallback will 404 until client is built');
|
||||
}
|
||||
app.use(express.static(clientDist));
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api')) return next();
|
||||
@@ -46,6 +58,7 @@ app.get('*', (req, res, next) => {
|
||||
|
||||
// WebSocket server
|
||||
const wss = new WebSocketServer({ server });
|
||||
setWss(wss);
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (message) => {
|
||||
// Echo — extensible for real-time notifications
|
||||
@@ -59,6 +72,10 @@ wss.on('connection', (ws) => {
|
||||
|
||||
async function start() {
|
||||
await db.initDB();
|
||||
// Warm up embeddings pipeline in background (non-blocking)
|
||||
embeddings.warmup().catch((err) => {
|
||||
console.warn('[index] embeddings warmup failed:', err.message);
|
||||
});
|
||||
server.listen(PORT, () => {
|
||||
console.log(`StudyOS server running on http://localhost:${PORT}`);
|
||||
console.log(`WebSocket server running on ws://localhost:${PORT}`);
|
||||
@@ -69,3 +86,5 @@ start().catch(err => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = { app, server, wss };
|
||||
|
||||
26
server/lib/broadcast.js
Normal file
26
server/lib/broadcast.js
Normal file
@@ -0,0 +1,26 @@
|
||||
let _wss = null;
|
||||
|
||||
function setWss(wss) {
|
||||
_wss = wss;
|
||||
}
|
||||
|
||||
function broadcast(payload) {
|
||||
if (!_wss) return;
|
||||
const data = JSON.stringify(payload);
|
||||
_wss.clients.forEach(ws => { if (ws.readyState === 1) ws.send(data); });
|
||||
}
|
||||
|
||||
function broadcastBuddy(payload) {
|
||||
if (!_wss) return;
|
||||
_wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
console.error('[ws] buddy broadcast error:', err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { setWss, broadcast, broadcastBuddy };
|
||||
142
server/lib/embeddings.js
Normal file
142
server/lib/embeddings.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
let pipelinePromise = null;
|
||||
let _transformers = null;
|
||||
|
||||
// LRU cache: sha1(text) -> Float32Array, capped at 256
|
||||
const lru = new Map();
|
||||
const LRU_MAX = 256;
|
||||
|
||||
function _lruKey(text) {
|
||||
return crypto.createHash('sha1').update(text).digest('hex');
|
||||
}
|
||||
|
||||
function _lruGet(key) {
|
||||
const val = lru.get(key);
|
||||
if (val !== undefined) {
|
||||
// move to back (most recently used)
|
||||
lru.delete(key);
|
||||
lru.set(key, val);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function _lruSet(key, vec) {
|
||||
if (lru.has(key)) {
|
||||
lru.delete(key);
|
||||
} else if (lru.size >= LRU_MAX) {
|
||||
const firstKey = lru.keys().next().value;
|
||||
lru.delete(firstKey);
|
||||
}
|
||||
lru.set(key, vec);
|
||||
}
|
||||
|
||||
async function _getPipeline() {
|
||||
if (pipelinePromise) return pipelinePromise;
|
||||
|
||||
pipelinePromise = (async () => {
|
||||
try {
|
||||
const mod = await import('@xenova/transformers');
|
||||
_transformers = mod;
|
||||
mod.env.cacheDir = path.join(__dirname, '..', '..', 'node_modules', '.cache', 'transformers');
|
||||
|
||||
// Try webgpu first (DirectML on Windows/AMD), fallback to wasm
|
||||
let pipe;
|
||||
try {
|
||||
pipe = await mod.pipeline('feature-extraction', 'Xenova/multilingual-e5-small', {
|
||||
device: 'webgpu',
|
||||
});
|
||||
console.log('[embeddings] pipeline loaded with device=webgpu');
|
||||
} catch (gpuErr) {
|
||||
console.warn('[embeddings] webgpu failed, falling back to wasm:', gpuErr.message);
|
||||
pipe = await mod.pipeline('feature-extraction', 'Xenova/multilingual-e5-small', {
|
||||
device: 'wasm',
|
||||
});
|
||||
console.log('[embeddings] pipeline loaded with device=wasm');
|
||||
}
|
||||
return pipe;
|
||||
} catch (err) {
|
||||
console.error('[embeddings] failed to load pipeline:', err.message);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
return pipelinePromise;
|
||||
}
|
||||
|
||||
async function warmup() {
|
||||
try {
|
||||
await _getPipeline();
|
||||
} catch (err) {
|
||||
console.warn('[embeddings] warmup failed (model will retry on first use):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function embed(text) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
throw new Error('embed() requires a non-empty string');
|
||||
}
|
||||
|
||||
const key = _lruKey(text);
|
||||
const cached = _lruGet(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const pipe = await _getPipeline();
|
||||
const result = await pipe(text, { pooling: 'mean', normalize: true });
|
||||
const vec = result.data instanceof Float32Array ? result.data : new Float32Array(result.data);
|
||||
_lruSet(key, vec);
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function embedBatch(texts) {
|
||||
if (!Array.isArray(texts) || texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const uncached = [];
|
||||
const indices = [];
|
||||
const results = new Array(texts.length);
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const key = _lruKey(texts[i]);
|
||||
const cached = _lruGet(key);
|
||||
if (cached) {
|
||||
results[i] = cached;
|
||||
} else {
|
||||
uncached.push(texts[i]);
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (uncached.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const pipe = await _getPipeline();
|
||||
const BATCH_SIZE = 32;
|
||||
|
||||
for (let start = 0; start < uncached.length; start += BATCH_SIZE) {
|
||||
const batch = uncached.slice(start, start + BATCH_SIZE);
|
||||
const batchResult = await pipe(batch, { pooling: 'mean', normalize: true });
|
||||
// batchResult.data is a flat array for all batches; shape depends on library version
|
||||
// For Transformers.js v2, when batching, result.data is flat and we need to slice
|
||||
const dim = batch.length > 0 ? Math.floor(batchResult.data.length / batch.length) : 384;
|
||||
for (let b = 0; b < batch.length; b++) {
|
||||
const offset = b * dim;
|
||||
const vec = new Float32Array(batchResult.data.slice(offset, offset + dim));
|
||||
const originalIdx = indices[start + b];
|
||||
results[originalIdx] = vec;
|
||||
_lruSet(_lruKey(batch[b]), vec);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
warmup,
|
||||
embed,
|
||||
embedBatch,
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
const OpenAI = require('openai');
|
||||
|
||||
// NOTE: model objects carry api_key in memory — avoid logging full model objects.
|
||||
// Use model.name or model.provider only in log statements.
|
||||
|
||||
/**
|
||||
* Normalize Anthropic + OpenAI-compatible streams into one AsyncIterable.
|
||||
* Yields: { token, done, fullText } events.
|
||||
|
||||
87
server/lib/rag.js
Normal file
87
server/lib/rag.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const db = require('../db');
|
||||
const embeddings = require('./embeddings');
|
||||
|
||||
/**
|
||||
* Split text into chunks using a sliding window.
|
||||
* Default: 500 chars per chunk, 50 char overlap.
|
||||
* Cap at 200 chunks per PDF.
|
||||
*/
|
||||
function chunkText(text, size = 500, overlap = 50) {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
const step = size - overlap;
|
||||
const chunks = [];
|
||||
for (let i = 0; i < text.length; i += step) {
|
||||
chunks.push(text.slice(i, i + size));
|
||||
if (chunks.length >= 200) break;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity between two Float32Arrays.
|
||||
* Returns a value in [-1, 1].
|
||||
*/
|
||||
function cosineSimilarity(a, b) {
|
||||
if (a.length !== b.length) {
|
||||
throw new Error(`cosineSimilarity: length mismatch ${a.length} vs ${b.length}`);
|
||||
}
|
||||
let dot = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const ai = a[i];
|
||||
const bi = b[i];
|
||||
dot += ai * bi;
|
||||
normA += ai * ai;
|
||||
normB += bi * bi;
|
||||
}
|
||||
if (normA === 0 || normB === 0) return 0;
|
||||
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export embed for clarity.
|
||||
*/
|
||||
async function embedQuery(text) {
|
||||
return embeddings.embed(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find top K most relevant chunks for a query vector.
|
||||
* @param {Float32Array} queryVec
|
||||
* @param {number[]} pdfIds
|
||||
* @param {number} k
|
||||
* @returns {Promise<{pdf_id, chunk_index, content, similarity}[]>}
|
||||
*/
|
||||
async function topK(queryVec, pdfIds, k = 3) {
|
||||
if (!pdfIds || pdfIds.length === 0) return [];
|
||||
|
||||
const placeholders = pdfIds.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT pdf_id, chunk_index, vector, content FROM embeddings WHERE pdf_id IN (${placeholders})`
|
||||
).all(...pdfIds);
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const scored = rows.map((row) => {
|
||||
const buf = Buffer.from(row.vector);
|
||||
const chunkVec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
||||
const similarity = cosineSimilarity(queryVec, chunkVec);
|
||||
return {
|
||||
pdf_id: row.pdf_id,
|
||||
chunk_index: row.chunk_index,
|
||||
content: row.content,
|
||||
similarity,
|
||||
};
|
||||
});
|
||||
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
return scored.slice(0, k);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
chunkText,
|
||||
cosineSimilarity,
|
||||
embedQuery,
|
||||
topK,
|
||||
};
|
||||
24
server/lib/sm2.js
Normal file
24
server/lib/sm2.js
Normal file
@@ -0,0 +1,24 @@
|
||||
function sm2(prev, quality) {
|
||||
let { ease_factor: e, interval_days: i, repetitions: r } = prev;
|
||||
if (quality < 3) {
|
||||
r = 0;
|
||||
i = 1;
|
||||
} else {
|
||||
if (r === 0) i = 1;
|
||||
else if (r === 1) i = 6;
|
||||
else i = Math.round(i * e);
|
||||
r += 1;
|
||||
}
|
||||
e = Math.max(1.3, e + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
|
||||
// next_review uses local timezone — acceptable for a personal study app
|
||||
const next = new Date();
|
||||
next.setDate(next.getDate() + i);
|
||||
return {
|
||||
ease_factor: +e.toFixed(2),
|
||||
interval_days: i,
|
||||
repetitions: r,
|
||||
next_review: next.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { sm2 };
|
||||
1626
server/package-lock.json
generated
1626
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,18 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
"dev": "node --watch index.js",
|
||||
"test": "node --test routes/__tests__/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.0",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^4.70.0",
|
||||
"pdfjs-dist": "^4.4.168",
|
||||
"pdfkit": "^0.15.0",
|
||||
"playwright": "^1.60.0",
|
||||
"sql.js": "^1.10.0",
|
||||
"ws": "^8.18.0"
|
||||
|
||||
82
server/routes/__tests__/config.test.js
Normal file
82
server/routes/__tests__/config.test.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
|
||||
const configRoutes = require('../config');
|
||||
|
||||
function request(app, method, urlPath, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, '127.0.0.1', async () => {
|
||||
const port = server.address().port;
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
server.close(() => resolve(res));
|
||||
} catch (err) {
|
||||
server.close(() => reject(err));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('config routes', () => {
|
||||
let app;
|
||||
const realDb = path.resolve(__dirname, '..', '..', '..', 'data', 'studyos.db');
|
||||
const backupDb = path.resolve(__dirname, '..', '..', '..', 'data', 'studyos.db.bak');
|
||||
|
||||
before(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/config', configRoutes);
|
||||
if (fs.existsSync(realDb)) {
|
||||
fs.renameSync(realDb, backupDb);
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (fs.existsSync(backupDb)) {
|
||||
if (fs.existsSync(realDb)) fs.unlinkSync(realDb);
|
||||
fs.renameSync(backupDb, realDb);
|
||||
} else if (fs.existsSync(realDb)) {
|
||||
fs.unlinkSync(realDb);
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /api/config/backup returns 404 when DB missing', async () => {
|
||||
const res = await request(app, 'GET', '/api/config/backup');
|
||||
assert.strictEqual(res.status, 404);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.error, 'No database');
|
||||
});
|
||||
|
||||
it('POST /api/config/restore rejects invalid file', async () => {
|
||||
const form = new FormData();
|
||||
const blob = new Blob(['not-a-db']);
|
||||
form.append('file', blob, 'bad.db');
|
||||
|
||||
const res = await request(app, 'POST', '/api/config/restore', form);
|
||||
assert.strictEqual(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.error, 'Not a valid SQLite database');
|
||||
});
|
||||
|
||||
it('POST /api/config/restore accepts valid SQLite file', async () => {
|
||||
const header = Buffer.from('SQLite format 3\0');
|
||||
const dbContent = Buffer.concat([header, Buffer.alloc(100)]);
|
||||
|
||||
const form = new FormData();
|
||||
const blob = new Blob([dbContent]);
|
||||
form.append('file', blob, 'studyos.db');
|
||||
|
||||
const res = await request(app, 'POST', '/api/config/restore', form);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.ok, true);
|
||||
});
|
||||
});
|
||||
71
server/routes/__tests__/progress.test.js
Normal file
71
server/routes/__tests__/progress.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { describe, it, before } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const express = require('express');
|
||||
|
||||
// Pre-populate require.cache so progress routes get mock dependencies
|
||||
// without loading the real index.js (which would start the server)
|
||||
// or the real db.js (which would need sql.js init).
|
||||
const broadcastCalls = [];
|
||||
const mockBroadcast = (payload) => broadcastCalls.push(payload);
|
||||
|
||||
require.cache[require.resolve('../../index')] = {
|
||||
id: require.resolve('../../index'),
|
||||
filename: require.resolve('../../index'),
|
||||
loaded: true,
|
||||
exports: { broadcast: mockBroadcast },
|
||||
};
|
||||
|
||||
const mockDbRow = { topic: 'math', exercises_done: 5, exercises_correct: 4, last_session: '2024-01-01', notes: '[]' };
|
||||
|
||||
require.cache[require.resolve('../../db')] = {
|
||||
id: require.resolve('../../db'),
|
||||
filename: require.resolve('../../db'),
|
||||
loaded: true,
|
||||
exports: {
|
||||
prepare: () => ({
|
||||
run: () => ({ changes: 1, lastInsertRowid: 1 }),
|
||||
get: () => mockDbRow,
|
||||
all: () => [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const progressRoutes = require('../progress');
|
||||
|
||||
function request(app, method, urlPath, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, '127.0.0.1', async () => {
|
||||
const port = server.address().port;
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
|
||||
method,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
});
|
||||
server.close(() => resolve(res));
|
||||
} catch (err) {
|
||||
server.close(() => reject(err));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('progress routes', () => {
|
||||
let app;
|
||||
|
||||
before(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/progress', progressRoutes);
|
||||
});
|
||||
|
||||
it('PUT /api/progress/:topic triggers broadcast with progress_update', async () => {
|
||||
broadcastCalls.length = 0;
|
||||
const res = await request(app, 'PUT', '/api/progress/math', { correct: true });
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(broadcastCalls.length, 1);
|
||||
assert.strictEqual(broadcastCalls[0].type, 'progress_update');
|
||||
assert.ok(broadcastCalls[0].data);
|
||||
assert.strictEqual(broadcastCalls[0].data.topic, 'math');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ const express = require('express');
|
||||
const db = require('../db');
|
||||
const { buildSystemPrompt } = require('../systemPromptBuilder');
|
||||
const { streamCompletion } = require('../lib/llm');
|
||||
const { embedQuery, topK } = require('../lib/rag');
|
||||
const { broadcastBuddy } = require('../lib/broadcast');
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/chat/stream — SSE streaming endpoint
|
||||
@@ -54,27 +56,51 @@ router.post('/stream', async (req, res) => {
|
||||
|
||||
let pdfContents = [];
|
||||
if (pdf_ids.length > 0) {
|
||||
const validIds = pdf_ids.map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0);
|
||||
const validIds = pdf_ids.map(id => parseInt(id, 10)).filter(id => !Number.isNaN(id) && id > 0);
|
||||
if (validIds.length > 0) {
|
||||
const placeholders = validIds.map(() => '?').join(',');
|
||||
pdfContents = db.prepare(`SELECT * FROM pdfs WHERE id IN (${placeholders})`).all(...validIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build system prompt
|
||||
const systemPrompt = buildSystemPrompt(conv, progressRows, pdfContents, attachment_texts);
|
||||
// 2. RAG: embed user message and fetch top-k chunks
|
||||
let ragChunks = [];
|
||||
let difficulty = 'normal';
|
||||
try {
|
||||
// First-topic match — picks first progress row whose topic appears in the message.
|
||||
// Multi-topic messages will match only the first topic found.
|
||||
const activeProgress = progressRows.find((r) => r.topic && message.toLowerCase().includes(r.topic.toLowerCase()));
|
||||
difficulty = activeProgress?.difficulty_level || 'normal';
|
||||
} catch (err) {
|
||||
console.error('[chat] difficulty detection error:', err.message);
|
||||
difficulty = 'normal';
|
||||
}
|
||||
|
||||
if (pdf_ids.length > 0) {
|
||||
try {
|
||||
const queryVec = await embedQuery(message);
|
||||
const validPdfIds = pdf_ids.map(id => parseInt(id, 10)).filter(id => !Number.isNaN(id) && id > 0);
|
||||
if (validPdfIds.length > 0) {
|
||||
ragChunks = await topK(queryVec, validPdfIds, 3);
|
||||
}
|
||||
} catch (ragErr) {
|
||||
console.warn('[chat] RAG failed:', ragErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. Build system prompt
|
||||
const systemPrompt = buildSystemPrompt(conv, progressRows, pdfContents, attachment_texts, ragChunks, difficulty);
|
||||
|
||||
// 3. Load existing messages, removing consecutive duplicates
|
||||
const rawMessages = db.prepare(
|
||||
'SELECT id, role, content FROM messages WHERE conversation_id = ? ORDER BY id'
|
||||
).all(convId);
|
||||
|
||||
// Fix: remove trailing user messages from failed streams (no assistant after)
|
||||
let delCount = 0;
|
||||
while (rawMessages.length > 0 && rawMessages[rawMessages.length - 1].role === 'user') {
|
||||
const last = rawMessages.pop();
|
||||
db.prepare('DELETE FROM messages WHERE id = ?').run(last.id);
|
||||
delCount++;
|
||||
// Fix: only remove the VERY LAST message if it's a user message matching the current input
|
||||
const lastMsg = rawMessages[rawMessages.length - 1];
|
||||
if (lastMsg && lastMsg.role === 'user' && lastMsg.content === message) {
|
||||
rawMessages.pop();
|
||||
db.prepare('DELETE FROM messages WHERE id = ?').run(lastMsg.id);
|
||||
}
|
||||
|
||||
// Filter: skip duplicate consecutive users (keep only the last in sequence)
|
||||
@@ -93,6 +119,10 @@ router.post('/stream', async (req, res) => {
|
||||
{ role: 'user', content: message },
|
||||
];
|
||||
|
||||
// Save user message BEFORE streaming so it persists even if server crashes
|
||||
db.prepare('INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)')
|
||||
.run(convId, 'user', message);
|
||||
|
||||
// 5. Stream via llm.streamCompletion()
|
||||
let assistantText = '';
|
||||
let errorOccurred = false;
|
||||
@@ -160,26 +190,93 @@ router.post('/stream', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Upsert progress table for each exercise
|
||||
for (const exerciseLogged of exerciseLogs) {
|
||||
// Deduplicate exercises by topic+correct combination
|
||||
const seen = new Set();
|
||||
const dedupedLogs = [];
|
||||
for (const entry of exerciseLogs) {
|
||||
const key = `${entry.topic}|${entry.correct}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
dedupedLogs.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Upsert progress table for each exercise + track streaks
|
||||
let lastSuggestedTopic = null;
|
||||
let difficultyChanged = false;
|
||||
let newDifficulty = difficulty;
|
||||
let newGlobalStreak = 0;
|
||||
|
||||
for (const exerciseLogged of dedupedLogs) {
|
||||
const topic = exerciseLogged.topic;
|
||||
const correct = exerciseLogged.correct === true ? 1 : 0;
|
||||
const isWrong = correct === 0;
|
||||
|
||||
const existing = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
|
||||
if (existing) {
|
||||
let newWrongStreak = isWrong ? (existing.wrong_streak || 0) + 1 : 0;
|
||||
newGlobalStreak = isWrong ? (existing.global_wrong_streak || 0) + 1 : Math.max(0, (existing.global_wrong_streak || 0) - 1);
|
||||
let newDiff = existing.difficulty_level || 'normal';
|
||||
|
||||
// Difficulty adjustment based on global streak
|
||||
if (newGlobalStreak >= 3 && newDiff !== 'easy') {
|
||||
newDiff = 'easy';
|
||||
difficultyChanged = true;
|
||||
} else if (newGlobalStreak === 0 && existing.exercises_done > 0 && (existing.exercises_correct / existing.exercises_done) >= 0.8 && newDiff !== 'hard') {
|
||||
newDiff = 'hard';
|
||||
difficultyChanged = true;
|
||||
} else if (newGlobalStreak >= 1 && newGlobalStreak < 3 && newDiff === 'easy') {
|
||||
newDiff = 'normal';
|
||||
difficultyChanged = true;
|
||||
}
|
||||
|
||||
newDifficulty = newDiff;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE progress SET
|
||||
exercises_done = exercises_done + 1,
|
||||
exercises_correct = exercises_correct + ?,
|
||||
last_session = datetime('now'),
|
||||
notes = ?
|
||||
notes = ?,
|
||||
wrong_streak = ?,
|
||||
global_wrong_streak = ?,
|
||||
difficulty_level = ?
|
||||
WHERE topic = ?
|
||||
`).run(correct, existing.notes, topic);
|
||||
`).run(correct, existing.notes, newWrongStreak, newGlobalStreak, newDiff, topic);
|
||||
|
||||
// Auto-fork suggest after 2 consecutive wrong answers on same topic
|
||||
if (isWrong && newWrongStreak >= 2) {
|
||||
lastSuggestedTopic = topic;
|
||||
}
|
||||
} else {
|
||||
let newWrongStreak = isWrong ? 1 : 0;
|
||||
newGlobalStreak = isWrong ? 1 : 0;
|
||||
let newDiff = isWrong ? 'normal' : 'normal';
|
||||
db.prepare(`
|
||||
INSERT INTO progress (topic, exercises_done, exercises_correct, last_session, notes)
|
||||
VALUES (?, 1, ?, datetime('now'), '[]')
|
||||
`).run(topic, correct);
|
||||
INSERT INTO progress (topic, exercises_done, exercises_correct, last_session, notes, wrong_streak, global_wrong_streak, difficulty_level)
|
||||
VALUES (?, 1, ?, datetime('now'), '[]', ?, ?, ?)
|
||||
`).run(topic, correct, newWrongStreak, newGlobalStreak, newDiff);
|
||||
if (isWrong) {
|
||||
lastSuggestedTopic = topic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also detect wrong answers from response text heuristics (fallback when no exercise_logged)
|
||||
const wrongHeuristic = /incorrect|incorrecta|no es correcto|mal|error/i.test(assistantText);
|
||||
if (wrongHeuristic && exerciseLogs.length === 0) {
|
||||
// Topic extracted from first sentence (fragile but functional enough for heuristic fallback)
|
||||
const topic = message.split(/[.!?\n]/)[0].slice(0, 50);
|
||||
const existing = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
|
||||
if (existing) {
|
||||
const newWrongStreak = (existing.wrong_streak || 0) + 1;
|
||||
const newGlobalStreak = (existing.global_wrong_streak || 0) + 1;
|
||||
db.prepare(`
|
||||
UPDATE progress SET wrong_streak = ?, global_wrong_streak = ?, exercises_done = exercises_done + 1 WHERE topic = ?
|
||||
`).run(newWrongStreak, newGlobalStreak, topic);
|
||||
if (newWrongStreak >= 2) {
|
||||
lastSuggestedTopic = topic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,17 +287,31 @@ router.post('/stream', async (req, res) => {
|
||||
}
|
||||
cleanText = cleanText.trim();
|
||||
|
||||
// Save user message now that streaming succeeded
|
||||
db.prepare('INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)')
|
||||
.run(convId, 'user', message);
|
||||
|
||||
// Save assistant response
|
||||
db.prepare('INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)')
|
||||
const msgInfo = db.prepare('INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)')
|
||||
.run(convId, 'assistant', cleanText);
|
||||
const assistantMsgId = msgInfo.lastInsertRowid;
|
||||
|
||||
// Update conversation updated_at
|
||||
db.prepare("UPDATE conversations SET updated_at = datetime('now') WHERE id = ?").run(convId);
|
||||
|
||||
// Emit SSE events for streak/difficulty
|
||||
if (lastSuggestedTopic) {
|
||||
sendEvent({ auto_fork_suggest: { topic: lastSuggestedTopic, parent_id: convId, wrong_streak: 2 } });
|
||||
}
|
||||
if (difficultyChanged) {
|
||||
sendEvent({ difficulty_changed: { level: newDifficulty, global_wrong_streak: newGlobalStreak } });
|
||||
}
|
||||
|
||||
// Broadcast buddy message if conversation has buddy_meta
|
||||
if (conv.buddy_meta) {
|
||||
try {
|
||||
broadcastBuddy({ type: 'buddy_msg', conv_id: convId, msg_id: assistantMsgId });
|
||||
} catch (e) {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent({ done: true, full_text: cleanText });
|
||||
res.end();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
const DB_PATH = path.resolve(__dirname, '..', '..', 'data', 'studyos.db');
|
||||
const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'utf8');
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 } });
|
||||
|
||||
function checkAdminKey(req, res) {
|
||||
const adminKey = process.env.ADMIN_KEY || 'studyos-admin';
|
||||
const headerKey = req.headers['x-admin-key'];
|
||||
if (!headerKey || headerKey !== adminKey) {
|
||||
res.status(403).json({ error: 'Forbidden: invalid or missing admin key' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/config — all key-value pairs
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
@@ -46,6 +63,13 @@ router.post('/test-vlm', async (req, res) => {
|
||||
return res.status(400).json({ error: 'url is required' });
|
||||
}
|
||||
|
||||
// SSRF protection: only allow URLs matching the configured VLM endpoint
|
||||
const vlmConfig = db.prepare("SELECT value FROM config WHERE key = 'vlm_endpoint'").get();
|
||||
const allowed = (vlmConfig?.value || '').replace(/\/+$/, '');
|
||||
if (!url.startsWith(allowed)) {
|
||||
return res.status(400).json({ error: 'URL must match configured VLM endpoint' });
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const resp = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) });
|
||||
@@ -63,4 +87,25 @@ router.post('/test-vlm', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/backup', (req, res) => {
|
||||
if (!checkAdminKey(req, res)) return;
|
||||
if (!fs.existsSync(DB_PATH)) return res.status(404).json({ error: 'No database' });
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="studyos.db"');
|
||||
fs.createReadStream(DB_PATH).pipe(res);
|
||||
});
|
||||
|
||||
router.post('/restore', upload.single('file'), (req, res) => {
|
||||
if (!checkAdminKey(req, res)) return;
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const buf = req.file.buffer;
|
||||
if (buf.length < 16 || !buf.slice(0, 16).equals(SQLITE_MAGIC)) {
|
||||
return res.status(400).json({ error: 'Not a valid SQLite database' });
|
||||
}
|
||||
const tmpPath = DB_PATH + '.tmp';
|
||||
fs.writeFileSync(tmpPath, buf);
|
||||
fs.renameSync(tmpPath, DB_PATH);
|
||||
res.json({ ok: true, message: 'Restore complete — reload required' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db');
|
||||
const { buildSystemPrompt } = require('../systemPromptBuilder');
|
||||
const { streamCompletion } = require('../lib/llm');
|
||||
@@ -114,6 +115,7 @@ router.post('/:id/fork', (req, res) => {
|
||||
const newId = info.lastInsertRowid;
|
||||
|
||||
// Persist fork_point in config table
|
||||
// TODO: migrate fork_point to a dedicated column on conversations table instead of config
|
||||
db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
|
||||
.run(`fork_point_${newId}`, String(forkPoint));
|
||||
|
||||
@@ -169,11 +171,13 @@ router.post('/:id/merge', async (req, res) => {
|
||||
return res.status(500).json({ error: 'No model available for summarization' });
|
||||
}
|
||||
|
||||
const transcript = forkMessages.map(m => `${m.role}: ${m.content}`).join('\n\n');
|
||||
// Truncate to last 50 messages to avoid exceeding context window
|
||||
const recentMessages = forkMessages.slice(-50);
|
||||
const transcript = recentMessages.map(m => `${m.role}: ${m.content}`).join('\n\n');
|
||||
const summarizePrompt = `Resume en 2-3 oraciones qué aprendió el usuario en esta sesión sobre ${fork.title}:\n\n${transcript}`;
|
||||
|
||||
let summary = '';
|
||||
for await (const chunk of streamCompletion(model, [{ role: 'user', content: summarizePrompt }], '')) {
|
||||
for await (const chunk of streamCompletion(model, [{ role: 'user', content: summarizePrompt }], undefined)) {
|
||||
if (chunk.error) {
|
||||
return res.status(502).json({ error: chunk.error });
|
||||
}
|
||||
@@ -198,4 +202,107 @@ router.post('/:id/merge', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/share — generate a share token for buddy mode
|
||||
router.post('/:id/share', (req, res) => {
|
||||
const convId = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(convId)) {
|
||||
return res.status(400).json({ error: 'Invalid conversation id' });
|
||||
}
|
||||
|
||||
const { role_label = 'compañero' } = req.body;
|
||||
|
||||
try {
|
||||
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(convId);
|
||||
if (!conv) {
|
||||
return res.status(404).json({ error: 'Conversation not found' });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
db.prepare('INSERT INTO shared_conversations (token, conv_id, role_label) VALUES (?, ?, ?)')
|
||||
.run(token, convId, role_label);
|
||||
|
||||
const shareUrl = `${req.protocol}://${req.get('host')}/shared/${token}`;
|
||||
res.json({ token, share_url: shareUrl, role_label });
|
||||
} catch (err) {
|
||||
console.error('[conversations] share error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/conversations/shared/:token — get shared conversation data
|
||||
router.get('/shared/:token', (req, res) => {
|
||||
const { token } = req.params;
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'token is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const shared = db.prepare('SELECT * FROM shared_conversations WHERE token = ?').get(token);
|
||||
if (!shared) {
|
||||
return res.status(404).json({ error: 'Shared conversation not found' });
|
||||
}
|
||||
|
||||
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(shared.conv_id);
|
||||
if (!conv) {
|
||||
return res.status(404).json({ error: 'Conversation not found' });
|
||||
}
|
||||
|
||||
const messages = db.prepare(
|
||||
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at, id'
|
||||
).all(shared.conv_id);
|
||||
|
||||
// Find counterpart label (if there are 2 shared entries for same conv)
|
||||
const allShared = db.prepare('SELECT * FROM shared_conversations WHERE conv_id = ?').all(shared.conv_id);
|
||||
const counterpart = allShared.find((s) => s.token !== token);
|
||||
|
||||
res.json({
|
||||
conversation: conv,
|
||||
messages,
|
||||
role_label: shared.role_label,
|
||||
counterpart_label: counterpart?.role_label || 'compañero',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[conversations] shared get error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conversations/join — join a shared conversation by token
|
||||
router.post('/join', (req, res) => {
|
||||
const { share_token, user_name } = req.body;
|
||||
if (!share_token || !user_name) {
|
||||
return res.status(400).json({ error: 'share_token and user_name are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const shared = db.prepare('SELECT * FROM shared_conversations WHERE token = ?').get(share_token);
|
||||
if (!shared) {
|
||||
return res.status(404).json({ error: 'Shared conversation not found' });
|
||||
}
|
||||
|
||||
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(shared.conv_id);
|
||||
if (!conv) {
|
||||
return res.status(404).json({ error: 'Conversation not found' });
|
||||
}
|
||||
|
||||
// Track participant
|
||||
db.prepare('INSERT INTO conversation_participants (conversation_id, user_name) VALUES (?, ?)')
|
||||
.run(shared.conv_id, user_name);
|
||||
|
||||
const messages = db.prepare(
|
||||
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at, id'
|
||||
).all(shared.conv_id);
|
||||
|
||||
res.json({
|
||||
conversation: conv,
|
||||
messages,
|
||||
role_label: shared.role_label,
|
||||
share_token,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[conversations] join error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
278
server/routes/exams.js
Normal file
278
server/routes/exams.js
Normal file
@@ -0,0 +1,278 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { streamCompletion } = require('../lib/llm');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/exams — list all exams, optionally filter by topic
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const topic = req.query.topic;
|
||||
let rows;
|
||||
if (topic) {
|
||||
rows = db.prepare('SELECT * FROM exams WHERE topics LIKE ? ORDER BY taken_at DESC').all(`%${topic}%`);
|
||||
} else {
|
||||
rows = db.prepare('SELECT * FROM exams ORDER BY taken_at DESC').all();
|
||||
}
|
||||
const result = rows.map((r) => ({
|
||||
...r,
|
||||
topics: JSON.parse(r.topics || '[]'),
|
||||
}));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[exams] list error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/exams — create a new exam
|
||||
router.post('/', (req, res) => {
|
||||
const { title, score, topics, taken_at } = req.body;
|
||||
if (!title || score === undefined) {
|
||||
return res.status(400).json({ error: 'title and score are required' });
|
||||
}
|
||||
try {
|
||||
const topicsJson = JSON.stringify(topics || []);
|
||||
const takenAt = taken_at || new Date().toISOString();
|
||||
const info = db.prepare(
|
||||
'INSERT INTO exams (title, score, topics, taken_at) VALUES (?, ?, ?, ?)'
|
||||
).run(title, score, topicsJson, takenAt);
|
||||
const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(info.lastInsertRowid);
|
||||
res.status(201).json({ ...row, topics: JSON.parse(row.topics || '[]') });
|
||||
} catch (err) {
|
||||
console.error('[exams] create error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/exams/:id — update an exam
|
||||
router.put('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const { title, score, topics, taken_at } = req.body;
|
||||
try {
|
||||
const existing = db.prepare('SELECT * FROM exams WHERE id = ?').get(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Exam not found' });
|
||||
}
|
||||
const newTitle = title !== undefined ? title : existing.title;
|
||||
const newScore = score !== undefined ? score : existing.score;
|
||||
const newTopics = topics !== undefined ? JSON.stringify(topics) : existing.topics;
|
||||
const newTakenAt = taken_at !== undefined ? taken_at : existing.taken_at;
|
||||
db.prepare(
|
||||
'UPDATE exams SET title = ?, score = ?, topics = ?, taken_at = ? WHERE id = ?'
|
||||
).run(newTitle, newScore, newTopics, newTakenAt, id);
|
||||
const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(id);
|
||||
res.json({ ...row, topics: JSON.parse(row.topics || '[]') });
|
||||
} catch (err) {
|
||||
console.error('[exams] update error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/exams/:id — delete an exam
|
||||
router.delete('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
try {
|
||||
const info = db.prepare('DELETE FROM exams WHERE id = ?').run(id);
|
||||
if (info.changes === 0) {
|
||||
return res.status(404).json({ error: 'Exam not found' });
|
||||
}
|
||||
res.json({ deleted: true });
|
||||
} catch (err) {
|
||||
console.error('[exams] delete error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/exams/:id — get a single exam
|
||||
router.get('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(id);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Exam not found' });
|
||||
}
|
||||
res.json({
|
||||
...row,
|
||||
topics: JSON.parse(row.topics || '[]'),
|
||||
questions: row.questions ? JSON.parse(row.questions) : [],
|
||||
answers: row.answers ? JSON.parse(row.answers) : [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[exams] get error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/exams/:id/start — start an exam (set started_at and status)
|
||||
router.post('/:id/start', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
try {
|
||||
const exam = db.prepare('SELECT * FROM exams WHERE id = ?').get(id);
|
||||
if (!exam) {
|
||||
return res.status(404).json({ error: 'Exam not found' });
|
||||
}
|
||||
db.prepare("UPDATE exams SET started_at = datetime('now'), status = ? WHERE id = ?")
|
||||
.run('in_progress', id);
|
||||
const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(id);
|
||||
res.json({
|
||||
...row,
|
||||
topics: JSON.parse(row.topics || '[]'),
|
||||
questions: row.questions ? JSON.parse(row.questions) : [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[exams] start error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/exams/generate — generate an exam via LLM
|
||||
router.post('/generate', async (req, res) => {
|
||||
const { conversation_id, topic, pdf_ids, num_questions = 5, duration_seconds = 600 } = req.body;
|
||||
if (!topic) {
|
||||
return res.status(400).json({ error: 'topic is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get model
|
||||
let model = null;
|
||||
if (conversation_id) {
|
||||
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversation_id);
|
||||
if (conv && conv.model_id) {
|
||||
model = db.prepare('SELECT * FROM models WHERE id = ?').get(conv.model_id);
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
model = db.prepare('SELECT * FROM models WHERE is_default_exam = 1 LIMIT 1').get();
|
||||
}
|
||||
if (!model) {
|
||||
model = db.prepare('SELECT * FROM models WHERE is_default_main = 1 LIMIT 1').get();
|
||||
}
|
||||
if (!model) {
|
||||
return res.status(500).json({ error: 'No model available for exam generation' });
|
||||
}
|
||||
|
||||
// Build prompt for exam generation
|
||||
const prompt = `Generá un examen simulado sobre "${topic}" con exactamente ${num_questions} preguntas. Respondé ÚNICAMENTE con un JSON array donde cada elemento tiene: { "q": "pregunta", "options": ["opción A", "opción B", "opción C", "opción D"], "answer": 0 } (answer es el índice correcto, 0-based). No incluyas texto adicional fuera del JSON.`;
|
||||
|
||||
let jsonText = '';
|
||||
for await (const chunk of streamCompletion(model, [{ role: 'user', content: prompt }], undefined)) {
|
||||
if (chunk.error) {
|
||||
return res.status(502).json({ error: chunk.error });
|
||||
}
|
||||
if (chunk.done) {
|
||||
jsonText = chunk.fullText;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract JSON from response
|
||||
let questions = [];
|
||||
try {
|
||||
const fenceMatch = jsonText.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
if (fenceMatch) {
|
||||
questions = JSON.parse(fenceMatch[1]);
|
||||
} else {
|
||||
questions = JSON.parse(jsonText);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.error('[exams] JSON parse error:', parseErr.message, 'raw:', jsonText.slice(0, 500));
|
||||
return res.status(502).json({ error: 'Failed to parse exam questions from model response' });
|
||||
}
|
||||
|
||||
if (!Array.isArray(questions) || questions.length === 0) {
|
||||
return res.status(502).json({ error: 'Model returned empty or invalid questions' });
|
||||
}
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const info = db.prepare(
|
||||
'INSERT INTO exams (title, score, topics, taken_at, questions, duration_seconds, started_at, status, conversation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
`Examen: ${topic}`,
|
||||
0,
|
||||
JSON.stringify([topic]),
|
||||
startedAt,
|
||||
JSON.stringify(questions),
|
||||
duration_seconds,
|
||||
startedAt,
|
||||
'in_progress',
|
||||
conversation_id || null
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(info.lastInsertRowid);
|
||||
res.status(201).json({
|
||||
id: row.id,
|
||||
questions,
|
||||
started_at: startedAt,
|
||||
duration_seconds,
|
||||
status: 'in_progress',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[exams] generate error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/exams/:id/submit — submit answers with grace period
|
||||
router.post('/:id/submit', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
|
||||
const { answers } = req.body;
|
||||
if (!Array.isArray(answers)) {
|
||||
return res.status(400).json({ error: 'answers array is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const exam = db.prepare('SELECT * FROM exams WHERE id = ?').get(id);
|
||||
if (!exam) {
|
||||
return res.status(404).json({ error: 'Exam not found' });
|
||||
}
|
||||
|
||||
if (exam.status === 'completed') {
|
||||
return res.status(400).json({ error: 'Exam already submitted' });
|
||||
}
|
||||
|
||||
const questions = JSON.parse(exam.questions || '[]');
|
||||
const now = Date.now();
|
||||
const startedAt = new Date(exam.started_at).getTime();
|
||||
const durationMs = (exam.duration_seconds || 0) * 1000;
|
||||
const graceMs = 5000;
|
||||
|
||||
if (durationMs > 0 && now > startedAt + durationMs + graceMs) {
|
||||
return res.status(410).json({ error: 'Exam time expired (grace period exceeded)' });
|
||||
}
|
||||
|
||||
// Score
|
||||
let correct = 0;
|
||||
for (let i = 0; i < Math.min(answers.length, questions.length); i++) {
|
||||
if (answers[i] === questions[i].answer) {
|
||||
correct++;
|
||||
}
|
||||
}
|
||||
const score = questions.length > 0 ? Math.round((correct / questions.length) * 100) : 0;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE exams SET answers = ?, score = ?, status = ? WHERE id = ?'
|
||||
).run(JSON.stringify(answers), score, 'completed', id);
|
||||
|
||||
res.json({ id, score, status: 'completed', correct, total: questions.length });
|
||||
} catch (err) {
|
||||
console.error('[exams] submit error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
272
server/routes/flashcards.js
Normal file
272
server/routes/flashcards.js
Normal file
@@ -0,0 +1,272 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { streamCompletion } = require('../lib/llm');
|
||||
const { sm2 } = require('../lib/sm2');
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/flashcards/generate — SSE stream from LLM
|
||||
router.post('/generate', async (req, res) => {
|
||||
const { source, pdf_id, message_id, topic } = req.body;
|
||||
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendEvent = (obj) => {
|
||||
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
let promptContent = '';
|
||||
if (source === 'pdf' && pdf_id) {
|
||||
const pdf = db.prepare('SELECT content_markdown FROM pdfs WHERE id = ?').get(pdf_id);
|
||||
if (!pdf) {
|
||||
sendEvent({ error: 'PDF not found' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
promptContent = pdf.content_markdown;
|
||||
} else if (source === 'exercise' && message_id) {
|
||||
const msg = db.prepare('SELECT content FROM messages WHERE id = ?').get(message_id);
|
||||
if (!msg) {
|
||||
sendEvent({ error: 'Message not found' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
promptContent = msg.content;
|
||||
} else {
|
||||
sendEvent({ error: 'Invalid source or missing id' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find default model
|
||||
let model = db.prepare('SELECT * FROM models WHERE is_default_main = 1 LIMIT 1').get();
|
||||
if (!model) {
|
||||
model = db.prepare('SELECT * FROM models LIMIT 1').get();
|
||||
}
|
||||
if (!model) {
|
||||
sendEvent({ error: 'No model configured' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const systemPrompt = 'You are a study assistant. Generate flashcards as a JSON array of {question, answer} objects from the provided text. Return ONLY a JSON code block wrapped in ```json ... ```. Do not include any other text.';
|
||||
const messages = [{ role: 'user', content: `Generate flashcards from the following text:\n\n${promptContent}` }];
|
||||
|
||||
let fullText = '';
|
||||
for await (const chunk of streamCompletion(model, messages, systemPrompt)) {
|
||||
if (chunk.error) {
|
||||
sendEvent({ error: chunk.error });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (chunk.token) {
|
||||
fullText += chunk.token;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON fences — use global flag to capture all blocks
|
||||
const fenceRegex = /```json\s*([\s\S]*?)\s*```/g;
|
||||
const matches = [...fullText.matchAll(fenceRegex)];
|
||||
let cards = [];
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
if (Array.isArray(parsed)) {
|
||||
cards.push(...parsed);
|
||||
} else if (parsed && Array.isArray(parsed.flashcards)) {
|
||||
cards.push(...parsed.flashcards);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[flashcards] JSON parse error:', e.message);
|
||||
}
|
||||
}
|
||||
if (!cards.length) {
|
||||
// Try parsing the whole text as JSON
|
||||
try {
|
||||
const parsed = JSON.parse(fullText);
|
||||
if (Array.isArray(parsed)) {
|
||||
cards = parsed;
|
||||
} else if (parsed && Array.isArray(parsed.flashcards)) {
|
||||
cards = parsed.flashcards;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[flashcards] JSON parse error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cards.length) {
|
||||
sendEvent({ error: 'No flashcards generated' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = [];
|
||||
for (const card of cards) {
|
||||
const q = card.question || card.q || '';
|
||||
const a = card.answer || card.a || '';
|
||||
if (!q || !a) continue;
|
||||
const info = db.prepare(
|
||||
'INSERT INTO flashcards (question, answer, pdf_id, message_id, topic) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(q, a, source === 'pdf' ? pdf_id : null, source === 'exercise' ? message_id : null, topic || null);
|
||||
const row = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(info.lastInsertRowid);
|
||||
inserted.push(row);
|
||||
sendEvent({ type: 'card', card: row });
|
||||
}
|
||||
|
||||
sendEvent({ type: 'done', total: inserted.length });
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.error('[flashcards] generate error:', err.message);
|
||||
sendEvent({ error: err.message });
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/flashcards — list flashcards
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const seen = req.query.seen;
|
||||
const topic = req.query.topic;
|
||||
let sql = 'SELECT * FROM flashcards WHERE 1=1';
|
||||
const params = [];
|
||||
if (seen !== undefined) {
|
||||
sql += ' AND seen = ?';
|
||||
params.push(seen === '1' || seen === 'true' ? 1 : 0);
|
||||
}
|
||||
if (topic) {
|
||||
sql += ' AND topic = ?';
|
||||
params.push(topic);
|
||||
}
|
||||
sql += ' ORDER BY created_at DESC';
|
||||
const rows = db.prepare(sql).all(...params);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[flashcards] list error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/flashcards/:id — update a flashcard
|
||||
router.put('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const { question, answer, seen, topic } = req.body;
|
||||
try {
|
||||
const existing = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Flashcard not found' });
|
||||
}
|
||||
const newQuestion = question !== undefined ? question : existing.question;
|
||||
const newAnswer = answer !== undefined ? answer : existing.answer;
|
||||
const newSeen = seen !== undefined ? (seen ? 1 : 0) : existing.seen;
|
||||
const newTopic = topic !== undefined ? topic : existing.topic;
|
||||
db.prepare(
|
||||
'UPDATE flashcards SET question = ?, answer = ?, seen = ?, topic = ? WHERE id = ?'
|
||||
).run(newQuestion, newAnswer, newSeen, newTopic, id);
|
||||
const row = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(id);
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
console.error('[flashcards] update error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/flashcards/:id — delete a flashcard
|
||||
router.delete('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
try {
|
||||
const info = db.prepare('DELETE FROM flashcards WHERE id = ?').run(id);
|
||||
if (info.changes === 0) {
|
||||
return res.status(404).json({ error: 'Flashcard not found' });
|
||||
}
|
||||
res.json({ deleted: true });
|
||||
} catch (err) {
|
||||
console.error('[flashcards] delete error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/flashcards/reviews/due — due flashcards with review state
|
||||
router.get('/reviews/due', (req, res) => {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT f.id, f.question, f.answer, f.topic,
|
||||
COALESCE(r.ease_factor, 2.5) as ease_factor,
|
||||
COALESCE(r.interval_days, 1) as interval_days,
|
||||
COALESCE(r.repetitions, 0) as repetitions,
|
||||
COALESCE(r.next_review, date('now')) as next_review,
|
||||
r.last_review
|
||||
FROM flashcards f
|
||||
LEFT JOIN flashcard_reviews r ON r.flashcard_id = f.id
|
||||
WHERE r.next_review IS NULL OR r.next_review <= date('now')
|
||||
ORDER BY r.next_review ASC, f.created_at DESC
|
||||
`).all();
|
||||
|
||||
const count = rows.length;
|
||||
res.json({ count, cards: rows });
|
||||
} catch (err) {
|
||||
console.error('[flashcards] reviews/due error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/flashcards/:id/review — submit SM-2 review
|
||||
router.put('/:id/review', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const { quality } = req.body;
|
||||
if (quality === undefined || quality < 0 || quality > 5) {
|
||||
return res.status(400).json({ error: 'quality must be 0-5' });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Flashcard not found' });
|
||||
}
|
||||
|
||||
const review = db.prepare('SELECT * FROM flashcard_reviews WHERE flashcard_id = ?').get(id);
|
||||
const prev = review
|
||||
? {
|
||||
ease_factor: review.ease_factor,
|
||||
interval_days: review.interval_days,
|
||||
repetitions: review.repetitions,
|
||||
}
|
||||
: { ease_factor: 2.5, interval_days: 1, repetitions: 0 };
|
||||
|
||||
const next = sm2(prev, quality);
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (review) {
|
||||
db.prepare(`
|
||||
UPDATE flashcard_reviews
|
||||
SET ease_factor = ?, interval_days = ?, repetitions = ?, next_review = ?, last_review = ?
|
||||
WHERE flashcard_id = ?
|
||||
`).run(next.ease_factor, next.interval_days, next.repetitions, next.next_review, now, id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO flashcard_reviews (flashcard_id, ease_factor, interval_days, repetitions, next_review, last_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, next.ease_factor, next.interval_days, next.repetitions, next.next_review, now);
|
||||
}
|
||||
|
||||
res.json({ ...next, last_review: now });
|
||||
} catch (err) {
|
||||
console.error('[flashcards] review error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,10 +3,12 @@ const db = require('../db');
|
||||
const { streamCompletion } = require('../lib/llm');
|
||||
const router = express.Router();
|
||||
|
||||
const MODEL_PUBLIC_COLS = 'id, name, api_base, provider, is_default_main, is_default_fork, is_default_exam';
|
||||
|
||||
// GET /api/models — list all
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const rows = db.prepare('SELECT * FROM models ORDER BY id').all();
|
||||
const rows = db.prepare(`SELECT ${MODEL_PUBLIC_COLS} FROM models ORDER BY id`).all();
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[models] list error:', err.message);
|
||||
@@ -14,6 +16,24 @@ router.get('/', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/models/:id — get single model (no api_key)
|
||||
router.get('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid model id' });
|
||||
}
|
||||
try {
|
||||
const row = db.prepare(`SELECT ${MODEL_PUBLIC_COLS} FROM models WHERE id = ?`).get(id);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Model not found' });
|
||||
}
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
console.error('[models] get error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/models — create
|
||||
router.post('/', (req, res) => {
|
||||
const { name, api_base, api_key, provider, is_default_main, is_default_fork, is_default_exam } = req.body;
|
||||
@@ -41,7 +61,7 @@ router.post('/', (req, res) => {
|
||||
if (is_default_fork) unsetOtherDefaults(newId, 'is_default_fork');
|
||||
if (is_default_exam) unsetOtherDefaults(newId, 'is_default_exam');
|
||||
|
||||
const row = db.prepare('SELECT * FROM models WHERE id = ?').get(newId);
|
||||
const row = db.prepare(`SELECT ${MODEL_PUBLIC_COLS} FROM models WHERE id = ?`).get(newId);
|
||||
res.status(201).json(row);
|
||||
} catch (err) {
|
||||
console.error('[models] create error:', err.message);
|
||||
@@ -75,10 +95,10 @@ router.put('/:id', (req, res) => {
|
||||
is_default_exam = COALESCE(?, is_default_exam)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? null,
|
||||
api_base ?? null,
|
||||
name || null,
|
||||
api_base || null,
|
||||
api_key !== undefined ? (api_key === null ? '' : api_key) : null,
|
||||
provider ?? null,
|
||||
provider || null,
|
||||
is_default_main !== undefined ? (is_default_main ? 1 : 0) : null,
|
||||
is_default_fork !== undefined ? (is_default_fork ? 1 : 0) : null,
|
||||
is_default_exam !== undefined ? (is_default_exam ? 1 : 0) : null,
|
||||
@@ -89,7 +109,7 @@ router.put('/:id', (req, res) => {
|
||||
if (is_default_fork) unsetOtherDefaults(id, 'is_default_fork');
|
||||
if (is_default_exam) unsetOtherDefaults(id, 'is_default_exam');
|
||||
|
||||
const row = db.prepare('SELECT * FROM models WHERE id = ?').get(id);
|
||||
const row = db.prepare(`SELECT ${MODEL_PUBLIC_COLS} FROM models WHERE id = ?`).get(id);
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
console.error('[models] update error:', err.message);
|
||||
|
||||
@@ -3,6 +3,8 @@ const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../db');
|
||||
const { chunkText } = require('../lib/rag');
|
||||
const embeddings = require('../lib/embeddings');
|
||||
const router = express.Router();
|
||||
|
||||
const uploadDir = path.resolve(__dirname, '..', '..', 'data', 'uploads');
|
||||
@@ -45,7 +47,8 @@ async function vlmExtract(filePath, vlmConfig) {
|
||||
const base64 = buffer.toString('base64');
|
||||
const mimeType = 'application/pdf';
|
||||
|
||||
const resp = await fetch(`${vlmConfig.endpoint}/chat/completions`, {
|
||||
const baseUrl = vlmConfig.endpoint.replace(/\/+$/, '');
|
||||
const resp = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${vlmConfig.api_key}`,
|
||||
@@ -90,9 +93,10 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||
let usedVlm = false;
|
||||
|
||||
// 1. Try VLM first
|
||||
const vlmEndpoint = db.prepare("SELECT value FROM config WHERE key = 'vlm_endpoint'").get();
|
||||
const vlmApiKey = db.prepare("SELECT value FROM config WHERE key = 'vlm_api_key'").get();
|
||||
const vlmModel = db.prepare("SELECT value FROM config WHERE key = 'vlm_model'").get();
|
||||
const configs = db.prepare("SELECT key, value FROM config WHERE key IN ('vlm_endpoint','vlm_api_key','vlm_model')").all();
|
||||
const vlmEndpoint = configs.find(c => c.key === 'vlm_endpoint');
|
||||
const vlmApiKey = configs.find(c => c.key === 'vlm_api_key');
|
||||
const vlmModel = configs.find(c => c.key === 'vlm_model');
|
||||
|
||||
if (vlmEndpoint?.value && vlmApiKey?.value) {
|
||||
const vlmConfig = {
|
||||
@@ -101,7 +105,16 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||
model: vlmModel?.value || 'glm-4.6v',
|
||||
};
|
||||
text = await vlmExtract(filePath, vlmConfig);
|
||||
if (text) usedVlm = true;
|
||||
if (text) {
|
||||
usedVlm = true;
|
||||
// VLM succeeded — still get page count from PDF metadata
|
||||
try {
|
||||
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||
const data = new Uint8Array(fs.readFileSync(filePath));
|
||||
const doc = await pdfjsLib.getDocument({ data }).promise;
|
||||
pages = doc.numPages;
|
||||
} catch { /* keep pages = 0 if metadata read fails */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to pdfjs-dist
|
||||
@@ -119,7 +132,26 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(req.file.filename, req.file.originalname, text || '', pages || 0, reorderIndex);
|
||||
|
||||
const row = db.prepare('SELECT * FROM pdfs WHERE id = ?').get(info.lastInsertRowid);
|
||||
const pdfId = info.lastInsertRowid;
|
||||
|
||||
// Generate embeddings for the PDF text
|
||||
if (text && text.trim().length > 0) {
|
||||
try {
|
||||
const chunks = chunkText(text, 500, 50);
|
||||
const vectors = await embeddings.embedBatch(chunks);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const vec = vectors[i];
|
||||
const blob = Buffer.from(vec.buffer);
|
||||
db.prepare(
|
||||
'INSERT INTO embeddings (pdf_id, chunk_index, vector, content) VALUES (?, ?, ?, ?)'
|
||||
).run(pdfId, i, blob, chunks[i]);
|
||||
}
|
||||
} catch (embErr) {
|
||||
console.warn('[pdfs] embedding generation failed:', embErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM pdfs WHERE id = ?').get(pdfId);
|
||||
res.status(201).json({ ...row, used_vlm: usedVlm });
|
||||
} catch (err) {
|
||||
console.error('[pdfs] upload error:', err.message);
|
||||
@@ -196,10 +228,10 @@ router.delete('/:id', (req, res) => {
|
||||
|
||||
// Delete file from disk
|
||||
const filePath = path.join(uploadDir, row.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
|
||||
// Delete embeddings first
|
||||
db.prepare('DELETE FROM embeddings WHERE pdf_id = ?').run(id);
|
||||
db.prepare('DELETE FROM pdfs WHERE id = ?').run(id);
|
||||
res.json({ deleted: true });
|
||||
} catch (err) {
|
||||
@@ -208,4 +240,26 @@ router.delete('/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/pdfs/:id/embeddings — list embeddings for a PDF (debug)
|
||||
router.get('/:id/embeddings', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid pdf id' });
|
||||
}
|
||||
|
||||
try {
|
||||
const pdf = db.prepare('SELECT id FROM pdfs WHERE id = ?').get(id);
|
||||
if (!pdf) {
|
||||
return res.status(404).json({ error: 'PDF not found' });
|
||||
}
|
||||
|
||||
const rows = db.prepare('SELECT id, pdf_id, chunk_index, content, created_at FROM embeddings WHERE pdf_id = ? ORDER BY chunk_index')
|
||||
.all(id);
|
||||
res.json({ pdf_id: id, count: rows.length, chunks: rows });
|
||||
} catch (err) {
|
||||
console.error('[pdfs] embeddings error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { broadcast } = require('../lib/broadcast');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/progress — all topics with pct calculation
|
||||
@@ -52,7 +54,9 @@ router.put('/:topic', (req, res) => {
|
||||
|
||||
const row = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
|
||||
const pct = row.exercises_done > 0 ? Math.round((row.exercises_correct / row.exercises_done) * 100) : 0;
|
||||
res.json({ ...row, percentage: pct });
|
||||
const result = { ...row, percentage: pct };
|
||||
broadcast({ type: 'progress_update', data: result });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[progress] update error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -75,4 +79,99 @@ router.delete('/:topic', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/progress/sessions — UPSERT daily minutes
|
||||
router.post('/sessions', (req, res) => {
|
||||
const { date, minutes, topic } = req.body;
|
||||
if (!date || minutes === undefined) {
|
||||
return res.status(400).json({ error: 'date and minutes are required' });
|
||||
}
|
||||
try {
|
||||
// NULL != NULL in SQLite UNIQUE constraint — explicit NULL handling keeps things working
|
||||
const existing = db.prepare('SELECT * FROM study_sessions WHERE session_date = ? AND (topic = ? OR (topic IS NULL AND ? IS NULL))').get(date, topic || null, topic || null);
|
||||
if (existing) {
|
||||
db.prepare('UPDATE study_sessions SET minutes = minutes + ? WHERE id = ?').run(minutes, existing.id);
|
||||
} else {
|
||||
db.prepare('INSERT INTO study_sessions (session_date, minutes, topic) VALUES (?, ?, ?)').run(date, minutes, topic || null);
|
||||
}
|
||||
const row = db.prepare('SELECT * FROM study_sessions WHERE session_date = ? AND (topic = ? OR (topic IS NULL AND ? IS NULL))').get(date, topic || null, topic || null);
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
console.error('[progress] session error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/progress/heatmap — aggregated study minutes per day
|
||||
router.get('/heatmap', (req, res) => {
|
||||
try {
|
||||
const days = parseInt(req.query.days, 10) || 365;
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
const sinceStr = since.toISOString().split('T')[0];
|
||||
const rows = db.prepare(
|
||||
'SELECT session_date as date, SUM(minutes) as minutes FROM study_sessions WHERE session_date >= ? GROUP BY session_date ORDER BY session_date'
|
||||
).all(sinceStr);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[progress] heatmap error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/progress/exam/pdf — generate simulated exam PDF
|
||||
router.get('/exam/pdf', (req, res) => {
|
||||
try {
|
||||
const rows = db.prepare(
|
||||
'SELECT * FROM progress ORDER BY last_session DESC LIMIT 20'
|
||||
).all();
|
||||
|
||||
if (!rows.length) {
|
||||
return res.status(404).json({ error: 'No progress data' });
|
||||
}
|
||||
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
const filename = `examen-simulado-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
doc.pipe(res);
|
||||
res.on('error', () => { try { doc.end(); } catch {} });
|
||||
|
||||
// Header
|
||||
doc.fontSize(22).text('Examen Simulado', 50, 50);
|
||||
doc.fontSize(12).text(`Generado: ${new Date().toLocaleDateString('es-ES')}`, 50, 80);
|
||||
doc.moveDown(2);
|
||||
|
||||
// Topics table
|
||||
doc.fontSize(14).text('Temas evaluados', 50, doc.y);
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(10);
|
||||
rows.forEach((row, idx) => {
|
||||
const pct = row.exercises_done > 0 ? Math.round((row.exercises_correct / row.exercises_done) * 100) : 0;
|
||||
doc.text(`${idx + 1}. ${row.topic} — ${pct}% (${row.exercises_correct}/${row.exercises_done})`, 60, doc.y);
|
||||
});
|
||||
|
||||
if (rows.length === 20) {
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(9).fillColor('gray').text('Nota: se muestran los 20 temas más recientes.', 50, doc.y);
|
||||
doc.fillColor('black');
|
||||
}
|
||||
|
||||
doc.moveDown(2);
|
||||
doc.fontSize(14).text('Ejercicios de muestra', 50, doc.y);
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(11);
|
||||
rows.slice(0, 10).forEach((row, idx) => {
|
||||
doc.text(`${idx + 1}. Describe el tema "${row.topic}" con sus puntos clave.`, 60, doc.y);
|
||||
doc.moveDown(0.3);
|
||||
doc.text(' Respuesta: _______________________________________________', 60, doc.y);
|
||||
doc.moveDown(1);
|
||||
});
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
console.error('[progress] exam/pdf error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
108
server/routes/search.js
Normal file
108
server/routes/search.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/search?q=term
|
||||
router.get('/', (req, res) => {
|
||||
const q = req.query.q || '';
|
||||
const term = q.trim();
|
||||
if (!term) {
|
||||
return res.json({ messages: [], pdfs: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const messageResults = [];
|
||||
const pdfResults = [];
|
||||
const fts5 = db._fts5Available;
|
||||
|
||||
// Sanitize term for FTS5: quote each whitespace-separated term
|
||||
const safeTerm = term.split(/\s+/).filter(Boolean).map(t => `"${t.replace(/"/g, '""')}"`).join(' ');
|
||||
const fts5Term = fts5 ? safeTerm : null;
|
||||
|
||||
if (fts5 && fts5Term) {
|
||||
// FTS5 path: use MATCH with snippet highlighting and BM25 ranking
|
||||
const messageRows = db.prepare(`
|
||||
SELECT m.id, m.conversation_id, m.content,
|
||||
snippet(messages_fts, 1, '<b>', '</b>', '…', 16) as snippet,
|
||||
rank
|
||||
FROM messages_fts
|
||||
JOIN messages m ON m.id = messages_fts.rowid
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 25
|
||||
`).all(fts5Term);
|
||||
|
||||
const pdfRows = db.prepare(`
|
||||
SELECT p.id, p.original_name, p.content_markdown,
|
||||
snippet(pdfs_fts, 1, '<b>', '</b>', '…', 16) as snippet,
|
||||
rank
|
||||
FROM pdfs_fts
|
||||
JOIN pdfs p ON p.id = pdfs_fts.rowid
|
||||
WHERE pdfs_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 25
|
||||
`).all(fts5Term);
|
||||
|
||||
for (const row of messageRows) {
|
||||
messageResults.push({
|
||||
type: 'message',
|
||||
id: row.id,
|
||||
title: row.content?.slice(0, 60) || 'Mensaje',
|
||||
snippet: row.snippet || '',
|
||||
rank: row.rank || 0,
|
||||
conversation_id: row.conversation_id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of pdfRows) {
|
||||
pdfResults.push({
|
||||
type: 'pdf',
|
||||
id: row.id,
|
||||
title: row.original_name || 'PDF',
|
||||
snippet: row.snippet || '',
|
||||
rank: row.rank || 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// LIKE fallback (no ranking, no snippets)
|
||||
const likeTerm = `%${term}%`;
|
||||
const messageRows = db.prepare(
|
||||
`SELECT id, conversation_id, content FROM messages WHERE content LIKE ? LIMIT 25`
|
||||
).all(likeTerm);
|
||||
const pdfRows = db.prepare(
|
||||
`SELECT id, original_name, content_markdown FROM pdfs WHERE content_markdown LIKE ? LIMIT 25`
|
||||
).all(likeTerm);
|
||||
|
||||
for (const row of messageRows) {
|
||||
messageResults.push({
|
||||
type: 'message',
|
||||
id: row.id,
|
||||
title: row.content?.slice(0, 60) || 'Mensaje',
|
||||
snippet: row.content?.slice(0, 120) || '',
|
||||
rank: 0,
|
||||
conversation_id: row.conversation_id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of pdfRows) {
|
||||
pdfResults.push({
|
||||
type: 'pdf',
|
||||
id: row.id,
|
||||
title: row.original_name || 'PDF',
|
||||
snippet: row.content_markdown?.slice(0, 120) || '',
|
||||
rank: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return grouped results — keep message BM25 and PDF BM25 ranks separate (different indexes)
|
||||
messageResults.sort((a, b) => (a.rank || 0) - (b.rank || 0));
|
||||
pdfResults.sort((a, b) => (a.rank || 0) - (b.rank || 0));
|
||||
res.json({ messages: messageResults.slice(0, 25), pdfs: pdfResults.slice(0, 25) });
|
||||
} catch (err) {
|
||||
console.error('[search] error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,9 +2,9 @@
|
||||
* Builds the system prompt for a conversation based on its type, user progress,
|
||||
* available PDFs, and any attachment texts.
|
||||
*/
|
||||
function buildSystemPrompt(conversation, progressRows = [], pdfContents = [], attachmentTexts = []) {
|
||||
function buildSystemPrompt(conversation, progressRows = [], pdfContents = [], attachmentTexts = [], ragChunks = [], difficulty = 'normal') {
|
||||
if (conversation.type === 'main') {
|
||||
return buildMainPrompt(progressRows, pdfContents, attachmentTexts);
|
||||
return buildMainPrompt(progressRows, pdfContents, attachmentTexts, ragChunks, difficulty, conversation);
|
||||
}
|
||||
if (conversation.type === 'fork') {
|
||||
return buildForkPrompt(conversation);
|
||||
@@ -12,7 +12,7 @@ function buildSystemPrompt(conversation, progressRows = [], pdfContents = [], at
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildMainPrompt(progressRows, pdfContents, attachmentTexts) {
|
||||
function buildMainPrompt(progressRows, pdfContents, attachmentTexts, ragChunks, difficulty, conversation) {
|
||||
let prompt = `Sos un tutor de estudio personal especializado. Tu objetivo es ayudar al usuario a aprender de forma eficiente y con seguimiento real de su progreso.
|
||||
|
||||
PROGRESO ACTUAL DEL USUARIO:
|
||||
@@ -65,6 +65,29 @@ ${attachmentTexts.map((t, i) => `--- Adjunto ${i + 1} ---\n${t}`).join('\n\n')}
|
||||
`;
|
||||
}
|
||||
|
||||
if (ragChunks && ragChunks.length > 0) {
|
||||
prompt += `
|
||||
REFERENCE CONTEXT (fragmentos relevantes de PDFs):
|
||||
${ragChunks.map((c, i) => `[${i + 1}] (PDF ${c.pdf_id}, chunk ${c.chunk_index})\n${c.content}`).join('\n\n')}
|
||||
`;
|
||||
}
|
||||
|
||||
if (difficulty && difficulty !== 'normal') {
|
||||
const levelLabel = difficulty === 'easy' || difficulty === 'facil' ? 'FÁCIL' : difficulty === 'hard' || difficulty === 'dificil' ? 'DIFÍCIL' : 'NORMAL';
|
||||
prompt += `
|
||||
NIVEL: ${levelLabel} — adaptá la profundidad y el lenguaje al nivel.
|
||||
`;
|
||||
}
|
||||
|
||||
if (conversation && conversation.buddy_meta) {
|
||||
const meta = typeof conversation.buddy_meta === 'string' ? JSON.parse(conversation.buddy_meta) : conversation.buddy_meta;
|
||||
const a = meta.role_a || 'Estudiante A';
|
||||
const b = meta.role_b || 'Estudiante B';
|
||||
prompt += `
|
||||
MODO COMPAÑERO DE ESTUDIO: hay 2 usuarios llamados "${a}" y "${b}". Dirigite a ambos.
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
CAPACIDADES:
|
||||
- Generar exámenes simulados adaptados al progreso
|
||||
|
||||
Reference in New Issue
Block a user