Initial commit: StudyOS platform

This commit is contained in:
renato97
2026-06-08 16:53:18 -03:00
commit b7d1e7319f
39 changed files with 9815 additions and 0 deletions

53
.atl/skill-registry.md Normal file
View File

@@ -0,0 +1,53 @@
# Skill Registry — studyos
> Auto-generated by sdd-init on 2026-06-07
> 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. |
## 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.
### 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.
### 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.
## Project Conventions
No project conventions detected yet. Create AGENTS.md or CLAUDE.md in the project root to establish conventions.
## Testing Capabilities
| Capability | Status |
|------------|--------|
| Test Runner | ❌ Not found |
| Unit Tests | ❌ |
| Integration Tests | ❌ |
| E2E Tests | ❌ |
| Coverage | ❌ |
| Linter | ❌ |
| Type Checker | ❌ |
| Formatter | ❌ |
**Strict TDD Mode**: Disabled (no test runner detected)

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
data/*.db
.env
*.log
.DS_Store

76
README.md Normal file
View File

@@ -0,0 +1,76 @@
# StudyOS
Plataforma de estudio personal con IA. Chat con streaming, seguimiento de progreso, procesamiento de PDFs, y micro-chats temáticos (fork/merge).
## Stack
- **Backend**: Node.js + Express + SQLite (sql.js, WASM — sin compilación nativa)
- **Frontend**: React 18 + Vite
- **Streaming**: Server-Sent Events (SSE)
## Requisitos
- Node.js 18+
## Instalación
```bash
cd studyos/server && npm install
cd studyos/client && npm install
```
## Desarrollo
```bash
# Terminal 1 — Backend
cd studyos/server
node index.js
# Terminal 2 — Frontend
cd studyos/client
npm run dev
```
Abrí http://localhost:5173 en el navegador.
## Producción
```bash
cd studyos/client && npm run build
cd studyos/server && node index.js
```
Todo se sirve desde http://localhost:3001.
## Estructura
```
studyos/
├── server/ # Backend Express
│ ├── index.js # Servidor principal + init async
│ ├── db.js # Schema SQLite vía sql.js (WASM) + seed
│ ├── lib/llm.js # Adaptador SSE (Anthropic + OpenAI)
│ ├── routes/ # Endpoints REST
│ └── systemPromptBuilder.js
├── client/ # Frontend React + Vite
│ └── src/
│ ├── components/ # Sidebar, MainChat, ForkPanel, etc.
│ ├── hooks/ # useChat, usePdfs, useProgress
│ ├── pages/ # Settings
│ └── lib/ # API client
└── data/ # SQLite database (auto-creado)
```
## Configuración Inicial
1. La base de datos se crea automáticamente en `data/studyos.db`
2. El modelo por defecto es `claude-sonnet-4` (Anthropic)
3. Configurá tus API keys en Settings → Modelos
4. El endpoint VLM por defecto es `http://localhost:8080/vlm`
## Features
- 💬 Chat con streaming SSE (Anthropic + OpenAI-compatible)
- 📄 Subida y procesamiento de PDFs
- 📊 Seguimiento de progreso por tema
- 🔀 Micro-chats temáticos (fork/merge)
- 🎨 Tema oscuro con diseño pulido
- ⚙️ Configuración de modelos y VLM

16
cdp-test.js Normal file
View File

@@ -0,0 +1,16 @@
const http = require('http');
// Get page from CDP
http.get('http://localhost:9222/json', (res) => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
const pages = JSON.parse(data);
const page = pages.find(p => p.url.includes('localhost:3001'));
if (!page) { console.log('StudyOS page not found. Open pages:', pages.map(p => p.url)); return; }
console.log('Page found:', page.url);
console.log('DevTools URL:', page.devtoolsFrontendUrl);
console.log('Title:', page.title);
console.log('Page ID:', page.id);
});
}).on('error', e => console.log('CDP error:', e.message));

17
client/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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="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" />
</head>
<body style="background-color:var(--bg-base);">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3438
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
client/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "studyos-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"katex": "^0.17.0",
"lucide-react": "^0.440.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.26.0",
"recharts": "^2.12.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}

248
client/src/App.css Normal file
View File

@@ -0,0 +1,248 @@
html { background: var(--bg-base); }
.app-layout { display: flex; height: 100vh; overflow: hidden; position: relative; }
.app-layout::before {
content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 0;
background: radial-gradient(ellipse 80% 60% at 30% 20%, rgba(129,140,248,0.04) 0%, transparent 60%),
radial-gradient(ellipse 60% 80% at 70% 80%, rgba(192,132,252,0.03) 0%, transparent 50%);
}
.app-sidebar {
width: 260px; flex-shrink: 0; position: relative; z-index: 1;
background: linear-gradient(180deg, rgba(22,24,34,0.95) 0%, rgba(15,17,23,0.93) 100%);
border-right: 1px solid var(--border);
backdrop-filter: blur(30px) saturate(120%);
display: flex; flex-direction: column; overflow: hidden;
}
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; z-index: 1; }
.app-fork { width: 0; overflow: hidden; transition: width var(--transition-slow); flex-shrink: 0; }
.app-fork.open { width: 320px; }
/* Sidebar items */
.sidebar-pdf-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius-sm); background: var(--bg-glass); transition: all var(--transition-fast); cursor: default; border: 1px solid transparent; }
.sidebar-pdf-item:hover { background: var(--bg-elevated); border-color: var(--border-glow); transform: translateX(2px); }
.sidebar-drag-handle { background: none; border: none; color: var(--text-tertiary); cursor: grab; padding: 2px; }
.sidebar-pdf-name { flex: 1; font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-pdf-delete { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 2px; opacity: 0; transition: all var(--transition-fast); }
.sidebar-pdf-item:hover .sidebar-pdf-delete { opacity: 1; }
.sidebar-pdf-delete:hover { color: var(--accent-coral); }
.sidebar-upload-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; margin-top: 8px; padding: 10px; border: 1.5px dashed var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); font-size: 12px; cursor: pointer; transition: all var(--transition-fast); font-weight: 500; }
.sidebar-upload-btn:hover { border-color: var(--accent-info); color: var(--accent-info); background: rgba(129,140,248,0.06); }
.sidebar-conv-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 14px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-secondary); font-size: 13px; text-align: left; cursor: pointer; border-left: 3px solid transparent; transition: all var(--transition-fast); font-family: var(--font-ui); }
.sidebar-conv-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
.sidebar-conv-item.active { background: linear-gradient(90deg, rgba(129,140,248,0.08), transparent); color: var(--text-primary); border-left-color: var(--accent-info); font-weight: 500; }
.sidebar-note-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: var(--radius-sm); font-size: 12px; color: var(--text-secondary); cursor: pointer; transition: all var(--transition-fast); border: none; background: transparent; width: 100%; text-align: left; }
.sidebar-note-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
.sidebar-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--accent-info); padding: 0 4px; }
.sidebar-progress-bar { height: 5px; background: var(--bg-elevated); border-radius: var(--radius-pill); overflow: hidden; }
.sidebar-progress-bar > div { height: 100%; border-radius: var(--radius-pill); transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); }
.sidebar-nav-btn { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 14px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-secondary); font-size: 13px; cursor: pointer; transition: all var(--transition-fast); font-family: var(--font-ui); }
.sidebar-nav-btn:hover { background: var(--bg-elevated); color: var(--text-primary); }
/* Messages */
.message-bubble { animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1); animation-fill-mode: both; }
.message-bubble:hover { transform: none; }
/* Context merge badge - MUY VISIBLE */
.context-merge-badge {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 18px;
background: linear-gradient(135deg, rgba(52,211,153,0.12), rgba(129,140,248,0.08));
border: 1.5px solid rgba(52,211,153,0.35);
border-radius: var(--radius-md);
font-size: 13px; color: var(--text-primary);
animation: slideUp 0.5s cubic-bezier(0.4, 0, 0.2, 1);
max-width: 85%; margin: 16px auto;
backdrop-filter: blur(8px);
box-shadow: 0 0 20px rgba(52,211,153,0.08);
}
.context-merge-badge:hover { border-color: rgba(52,211,153,0.5); box-shadow: 0 0 28px rgba(52,211,153,0.12); }
/* 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-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; }
.fork-merge-btn { display: flex; align-items: center; gap: 6px; background: linear-gradient(135deg, var(--accent-green), #10b981); border: none; color: #0f1117; padding: 6px 14px; border-radius: var(--radius-pill); font-size: 11px; font-weight: 700; cursor: pointer; transition: all var(--transition-fast); }
.fork-merge-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: var(--shadow-glow-green); }
.fork-close-btn { display: flex; align-items: center; gap: 4px; background: transparent; border: 1px solid var(--border); color: var(--text-tertiary); padding: 6px 12px; border-radius: var(--radius-pill); cursor: pointer; font-size: 11px; transition: all var(--transition-fast); }
.fork-close-btn:hover { color: var(--accent-coral); border-color: var(--accent-coral); }
/* Streaming */
.streaming-indicator { display: flex; align-items: center; gap: 10px; padding: 12px 16px; color: var(--text-tertiary); font-size: 12px; font-family: var(--font-ui); }
.streaming-indicator-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-info); animation: pulse 1.5s ease-in-out infinite; }
/* Chat input */
.chat-input-textarea { flex: 1; background: var(--bg-elevated); border: 1.5px solid var(--border); border-radius: var(--radius-lg); color: var(--text-primary); font-family: var(--font-ui); font-size: 14px; padding: 12px 18px; resize: none; outline: none; max-height: 120px; line-height: 1.5; transition: all var(--transition-fast); }
.chat-input-textarea:focus { border-color: var(--accent-info); box-shadow: 0 0 0 4px rgba(129,140,248,0.1); }
.chat-input-textarea:disabled { opacity: 0.4; }
.chat-input-send-btn { display: flex; align-items: center; padding: 10px; border-radius: var(--radius-lg); border: none; cursor: pointer; transition: all var(--transition-fast); }
.chat-input-send-btn:hover:not(:disabled) { transform: scale(1.08); filter: brightness(1.1); }
/* Empty state */
.chat-empty-state { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-tertiary); font-size: 16px; gap: 16px; text-align: center; padding: 48px; }
.chat-empty-state-icon { opacity: 0.25; }
/* Model selector */
.model-selector { position: relative; }
.model-selector-dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; min-width: 260px; background: rgba(30,32,48,0.99); border: 1px solid var(--border-glow); border-radius: var(--radius-md); padding: 6px; z-index: 50; backdrop-filter: blur(24px); box-shadow: var(--shadow-lg); animation: scaleIn 0.15s ease; }
.model-selector-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: var(--radius-sm); cursor: pointer; transition: all var(--transition-fast); }
.model-selector-item:hover { background: var(--bg-surface); }
.model-selector-item.active { background: rgba(129,140,248,0.1); }
/* Markdown / code */
.markdown-body pre { background: #0a0c14; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 16px; overflow-x: auto; margin: 14px 0; }
.markdown-body pre code { font-family: var(--font-mono); font-size: 13px; line-height: 1.7; color: var(--text-primary); background: none; border: none; padding: 0; }
.markdown-body code { background: var(--bg-elevated); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; color: var(--accent-cyan); border: 1px solid var(--border); }
.markdown-body a { color: var(--accent-info); text-decoration: none; border-bottom: 1.5px solid rgba(129,140,248,0.3); transition: all var(--transition-fast); }
.markdown-body a:hover { border-bottom-color: var(--accent-info); }
.markdown-body blockquote { border-left: 3px solid var(--accent-info); padding: 10px 14px; margin: 10px 0; background: rgba(129,140,248,0.05); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
/* PDF dropdown */
.pdf-dropdown-menu { position: absolute; bottom: calc(100% + 8px); left: 0; background: rgba(30,32,48,0.99); border: 1px solid var(--border-glow); border-radius: var(--radius-md); padding: 6px; min-width: 200px; max-height: 240px; overflow-y: auto; z-index: 10; box-shadow: var(--shadow-lg); backdrop-filter: blur(24px); animation: scaleIn 0.15s ease; }
.pdf-dropdown-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; font-size: 12px; color: var(--text-secondary); cursor: pointer; transition: all var(--transition-fast); border-radius: var(--radius-sm); }
.pdf-dropdown-item:hover { background: var(--bg-surface); }
/* Chips */
.attachment-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--bg-elevated); color: var(--text-secondary); font-size: 11px; padding: 5px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border); }
.attachment-chip button { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 0; display: flex; align-items: center; transition: color var(--transition-fast); }
.attachment-chip button:hover { color: var(--accent-coral); }
/* Icon buttons */
.icon-btn { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 8px; display: flex; align-items: center; border-radius: var(--radius-sm); transition: all var(--transition-fast); }
.icon-btn:hover { color: var(--accent-info); background: var(--bg-elevated); }
/* Topbar */
.mainchat-topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid var(--border); flex-shrink: 0; background: rgba(15,17,23,0.85); backdrop-filter: blur(16px); }
/* Drag overlay */
.dnd-drag-overlay { background: var(--bg-elevated); border: 1px solid var(--border-glow); border-radius: var(--radius-sm); box-shadow: var(--shadow-md); padding: 8px 12px; display: flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: 12px; opacity: 0.95; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(6px); 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: var(--shadow-lg); animation: scaleIn 0.2s ease; }
.modal-close-btn { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 6px; border-radius: var(--radius-sm); transition: all var(--transition-fast); }
.modal-close-btn:hover { color: var(--accent-coral); background: var(--bg-elevated); }
/* Toggle */
.toggle-switch { position: relative; width: 38px; height: 22px; background: var(--border); border-radius: 99px; cursor: pointer; transition: all var(--transition-fast); border: none; padding: 0; }
.toggle-switch.on { background: var(--accent-info); box-shadow: 0 0 14px rgba(129,140,248,0.35); }
.toggle-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: transform var(--transition-fast); }
.toggle-switch.on::after { transform: translateX(16px); }
/* Form */
.form-group input, .form-group select { width: 100%; padding: 10px 14px; background: var(--bg-surface); border: 1.5px solid var(--border); border-radius: var(--radius-md); color: var(--text-primary); font-family: var(--font-ui); font-size: 13px; outline: none; transition: all var(--transition-fast); }
.form-group input:focus, .form-group select:focus { border-color: var(--accent-info); box-shadow: 0 0 0 4px rgba(129,140,248,0.1); }
.vlm-input { flex: 1; max-width: 440px; padding: 10px 14px; background: var(--bg-surface); border: 1.5px solid var(--border); border-radius: var(--radius-md); color: var(--text-primary); font-family: var(--font-ui); font-size: 13px; outline: none; transition: all var(--transition-fast); }
.vlm-input:focus { border-color: var(--accent-info); box-shadow: 0 0 0 4px rgba(129,140,248,0.1); }
.progress-chart-container { height: 300px; background: var(--bg-surface); border-radius: var(--radius-md); padding: 20px; border: 1px solid var(--border); }
/* Settings */
.settings-container { max-width: 960px; margin: 0 auto; padding: 36px 24px; }
.settings-tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--border); margin-bottom: 28px; }
.settings-tab { padding: 12px 24px; cursor: pointer; border-radius: var(--radius-md) var(--radius-md) 0 0; color: var(--text-secondary); font-size: 13px; font-weight: 600; transition: all var(--transition-fast); border: none; background: transparent; }
.settings-tab:hover { color: var(--text-primary); background: var(--bg-glass); }
.settings-tab.active { background: var(--bg-elevated); color: var(--accent-info); border-bottom: 2px solid var(--accent-info); margin-bottom: -2px; }
.settings-table { width: 100%; border-collapse: collapse; }
.settings-table th, .settings-table td { padding: 14px 16px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }
.settings-table th { color: var(--accent-info); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
.settings-table tr:hover td { background: var(--bg-glass); }
/* Buttons */
.btn { padding: 9px 20px; border-radius: var(--radius-md); font-size: 13px; font-weight: 600; cursor: pointer; transition: all var(--transition-fast); border: none; font-family: var(--font-ui); }
.btn:hover { transform: translateY(-1px); }
.btn-primary { background: linear-gradient(135deg, var(--accent-info), #6366f1); color: #fff; }
.btn-primary:hover { box-shadow: 0 4px 16px rgba(129,140,248,0.3); }
.btn-secondary { background: var(--bg-elevated); color: var(--text-secondary); border: 1.5px solid var(--border); }
.btn-secondary:hover { border-color: var(--text-tertiary); color: var(--text-primary); }
.btn-danger { background: linear-gradient(135deg, #ef4444, #dc2626); color: #fff; }
.btn-danger:hover { box-shadow: 0 4px 16px rgba(248,113,113,0.25); }
.btn-sm { padding: 6px 14px; font-size: 11px; }
/* Provider badges */
.provider-badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: var(--radius-pill); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
.provider-badge.anthropic { background: rgba(212,165,116,0.15); color: #f0c27a; }
.provider-badge.openai { background: rgba(129,140,248,0.15); color: #a5b4fc; }
/* Role dots */
.role-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.role-dot.main { background: var(--accent-info); box-shadow: 0 0 8px rgba(129,140,248,0.5); }
.role-dot.fork { background: var(--accent-amber); box-shadow: 0 0 8px rgba(251,191,36,0.5); }
.role-dot.exam { background: var(--accent-green); box-shadow: 0 0 8px rgba(52,211,153,0.5); }
/* Avatar */
.avatar-dot {
width: 30px; height: 30px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent-info), var(--accent-purple));
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: #fff;
box-shadow: 0 0 16px rgba(129,140,248,0.3); user-select: none;
transition: all var(--transition-fast);
}
.avatar-dot:hover { transform: scale(1.1); box-shadow: 0 0 24px rgba(129,140,248,0.5); }
/* Fork resize handle */
.fork-resize-handle {
width: 4px; cursor: col-resize; background: transparent;
transition: background var(--transition-fast); position: relative; z-index: 10;
}
.fork-resize-handle:hover, .fork-resize-handle:active {
background: var(--accent-info); box-shadow: 0 0 12px rgba(129,140,248,0.4);
}
/* Chat row hover effects */
.sidebar-conv-item:hover { transform: translateX(3px); }
.sidebar-pdf-item:hover { transform: translateX(2px); }
.sidebar-note-item:hover { transform: translateX(2px); }
/* 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; }
.modal-close-btn { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 6px; border-radius: var(--radius-sm); transition: all var(--transition-fast); }
.modal-close-btn:hover { color: var(--accent-coral); background: var(--bg-elevated); }
/* Button hover effects */
.btn:hover { transform: translateY(-2px); }
.btn:active { transform: translateY(0); }
.btn-primary { background: linear-gradient(135deg, var(--accent-info), #7c3aed); color: #fff; box-shadow: 0 2px 12px rgba(129,140,248,0.25); }
.btn-primary:hover { box-shadow: 0 4px 20px rgba(129,140,248,0.4); }
.btn-secondary { background: var(--bg-elevated); color: var(--text-secondary); border: 1.5px solid var(--border); }
.btn-secondary:hover { border-color: var(--accent-info); color: var(--accent-info); }
.btn-danger { background: linear-gradient(135deg, #ef4444, #dc2626); color: #fff; }
.btn-danger:hover { box-shadow: 0 4px 16px rgba(248,113,113,0.25); }
.progress-bar-bg { background: var(--bg-elevated); border-radius: 99px; height: 8px; overflow: hidden; }
.progress-bar-fill { height: 100%; border-radius: 99px; transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); }
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; }
.modal-header h3 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg, var(--accent-info), var(--accent-purple)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
.form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; }
/* Math rendering */
.katex { font-size: 1.1em !important; }
.katex-display { margin: 14px 0 !important; overflow-x: auto; overflow-y: hidden; }
.katex-display > .katex { font-size: 1.2em !important; }
/* Fork badge in chat */
.fork-inline-badge {
display: inline-flex; align-items: center; gap: 6px;
background: linear-gradient(135deg, rgba(251,191,36,0.12), rgba(249,115,22,0.08));
border: 1px solid rgba(251,191,36,0.3);
border-radius: var(--radius-sm); padding: 4px 10px;
font-size: 11px; font-weight: 600; color: var(--accent-amber);
cursor: pointer; transition: all var(--transition-fast);
margin: 0 4px;
}
.fork-inline-badge:hover { box-shadow: 0 0 12px rgba(251,191,36,0.2); border-color: var(--accent-amber); }

186
client/src/App.jsx Normal file
View File

@@ -0,0 +1,186 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Routes, Route, useNavigate } from 'react-router-dom';
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 useChat from './hooks/useChat';
import usePdfs from './hooks/usePdfs';
import useProgress from './hooks/useProgress';
import {
getConversations,
createConversation,
getModels,
forkConversation,
mergeConversation,
getNotes,
} from './lib/api';
import './App.css';
export default function App() {
const [conversaciones, setConversaciones] = useState([]);
const [conversationActiva, setConversationActiva] = useState(null);
const [forkActivo, setForkActivo] = useState(null);
const [modelos, setModelos] = useState([]);
const [modeloSeleccionado, setModeloSeleccionado] = useState(null);
const [notas, setNotas] = useState([]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const navigate = useNavigate();
const pdfsHook = usePdfs();
const progressHook = useProgress();
const chatHook = useChat({
conversationId: conversationActiva?.id ?? null,
onProgressUpdate: progressHook.updateExercise,
});
// Load initial data
useEffect(() => {
let mounted = true;
async function load() {
try {
const [models, convs, notesList] = await Promise.all([
getModels(),
getConversations(),
getNotes(),
]);
if (!mounted) return;
setModelos(models);
setConversaciones(convs);
setNotas(notesList);
const defaultModel = models.find((m) => m.is_default_main) || models[0] || null;
setModeloSeleccionado(defaultModel);
// Load PDFs and progress
pdfsHook.refresh();
progressHook.refresh();
} catch (err) {
console.error('[App] load error:', err.message);
}
}
load();
return () => { mounted = false; };
}, []);
// Load messages when active conversation changes
useEffect(() => {
if (!conversationActiva) return;
chatHook.setActiveId(conversationActiva.id);
}, [conversationActiva?.id]);
const handleSelectConversation = useCallback((conv) => {
setConversationActiva(conv);
setForkActivo(null);
}, []);
const handleNewConversation = useCallback(async () => {
try {
const conv = await createConversation({
title: 'Nueva conversación',
model_id: modeloSeleccionado?.id ?? null,
});
setConversaciones((prev) => [conv, ...prev]);
setConversationActiva(conv);
setForkActivo(null);
} catch (err) {
console.error('[App] new conversation error:', err.message);
}
}, [modeloSeleccionado]);
const handleFork = useCallback(async (topic) => {
if (!conversationActiva) return;
try {
const fork = await forkConversation(conversationActiva.id, {
topic,
model_id: modeloSeleccionado?.id ?? null,
});
setConversaciones((prev) => [fork, ...prev]);
setForkActivo(fork);
} catch (err) {
console.error('[App] fork error:', err.message);
}
}, [conversationActiva, modeloSeleccionado]);
const handleMergeFork = useCallback(async () => {
if (!forkActivo) return;
try {
await mergeConversation(forkActivo.id);
setForkActivo(null);
// Refresh conversations list (sidebar needs fresh data)
const convs = await getConversations();
setConversaciones(convs);
// Refresh messages of parent conversation
if (conversationActiva) {
chatHook.setActiveId(conversationActiva.id);
}
} catch (err) {
console.error('[App] merge error:', err.message);
}
}, [forkActivo, conversationActiva, chatHook]);
const handleCloseFork = useCallback(() => {
setForkActivo(null);
}, []);
const mainConvs = conversaciones.filter((c) => c.type === 'main');
return (
<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">
<MainChat
conversation={conversationActiva}
modelo={modeloSeleccionado}
modelos={modelos}
onModelChange={setModeloSeleccionado}
onFork={handleFork}
messages={chatHook.messages}
isStreaming={chatHook.isStreaming}
MessageBubbleComponent={MessageBubble}
/>
<ChatInput
onSend={(text, pdfIds, attachments) =>
chatHook.sendMessage(text, pdfIds, attachments)
}
isStreaming={chatHook.isStreaming}
availablePdfs={pdfsHook.pdfs}
/>
</main>
<ForkPanel
forkId={forkActivo?.id ?? null}
forkTitle={forkActivo?.title || ''}
onClose={handleCloseFork}
onMerge={handleMergeFork}
/>
</>
}
/>
<Route path="/settings" element={<Settings />} />
</Routes>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, ChevronDown, Check } from 'lucide-react';
export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
const [text, setText] = useState('');
const [attachedFiles, setAttachedFiles] = useState([]);
const [selectedPdfIds, setSelectedPdfIds] = useState([]);
const [pdfDropdownOpen, setPdfDropdownOpen] = useState(false);
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const dropdownRef = useRef(null);
// Auto-resize textarea (max ~5 lines ~120px)
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
const newHeight = Math.min(el.scrollHeight, 120);
el.style.height = `${newHeight}px`;
}, [text]);
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(e) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setPdfDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSend = () => {
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
const attachmentTexts = attachedFiles.map((f) => f.text);
onSend(trimmed, selectedPdfIds, attachmentTexts);
setText('');
setAttachedFiles([]);
setSelectedPdfIds([]);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
};
const handleFileChange = async (e) => {
const files = Array.from(e.target.files || []);
const loaded = await Promise.all(
files.map(
(file) =>
new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (ev) =>
resolve({ name: file.name, text: ev.target.result });
reader.onerror = () =>
resolve({ name: file.name, text: '' });
reader.readAsText(file);
})
)
);
setAttachedFiles((prev) => [...prev, ...loaded]);
e.target.value = '';
};
const removeAttachment = (index) => {
setAttachedFiles((prev) => prev.filter((_, i) => i !== index));
};
const togglePdf = (id) => {
setSelectedPdfIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const canSend = text.trim().length > 0 && !isStreaming;
return (
<div
style={{
borderTop: '1px solid var(--border)',
padding: '10px 16px',
background: 'var(--bg-surface)',
flexShrink: 0,
}}
>
{/* Attached files chips */}
{attachedFiles.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{attachedFiles.map((file, idx) => (
<span key={idx} className="attachment-chip">
{file.name}
<button onClick={() => removeAttachment(idx)}>
<X size={12} />
</button>
</span>
))}
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
{/* Attachment button */}
<button
onClick={() => fileInputRef.current?.click()}
title="Adjuntar archivo (.txt, .md, .ics)"
className="icon-btn"
>
<Paperclip size={18} />
</button>
<input
ref={fileInputRef}
type="file"
accept=".txt,.md,.ics"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* PDF selector */}
<div ref={dropdownRef} style={{ position: 'relative' }}>
<button
onClick={() => setPdfDropdownOpen((s) => !s)}
title="Seleccionar PDFs"
className="icon-btn"
style={{
color: selectedPdfIds.length ? 'var(--accent-green)' : undefined,
}}
>
<FileIcon size={18} />
{selectedPdfIds.length > 0 && (
<span style={{ marginLeft: 4, fontSize: 10, fontWeight: 700 }}>
{selectedPdfIds.length}
</span>
)}
<ChevronDown size={14} style={{ marginLeft: 2 }} />
</button>
{pdfDropdownOpen && (
<div className="pdf-dropdown-menu">
{availablePdfs.length === 0 && (
<div
style={{
padding: '8px 12px',
fontSize: 12,
color: 'var(--text-tertiary)',
}}
>
No hay PDFs
</div>
)}
{availablePdfs.map((pdf) => {
const checked = selectedPdfIds.includes(pdf.id);
return (
<label
key={pdf.id}
className="pdf-dropdown-item"
>
<input
type="checkbox"
checked={checked}
onChange={() => togglePdf(pdf.id)}
style={{ cursor: 'pointer' }}
/>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={pdf.original_name}
>
{pdf.original_name}
</span>
{checked && (
<Check size={12} style={{ marginLeft: 'auto', color: 'var(--accent-green)' }} />
)}
</label>
);
})}
</div>
)}
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe un mensaje..."
rows={1}
disabled={isStreaming}
className="chat-input-textarea"
/>
{/* Send button */}
<button
onClick={handleSend}
disabled={!canSend}
className="chat-input-send-btn"
style={{
background: canSend ? 'var(--accent-green)' : 'var(--bg-elevated)',
color: canSend ? '#fff' : 'var(--text-tertiary)',
}}
>
<Send size={18} />
</button>
</div>
</div>
);
}
function FileIcon(props) {
// Simple file icon similar to lucide FileText
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={props.size || 18}
height={props.size || 18}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
}

View File

@@ -0,0 +1,213 @@
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';
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 }) {
const [merging, setMerging] = useState(false);
const [inputText, setInputText] = useState('');
const messagesEndRef = useRef(null);
const chatHook = useChat({
conversationId: forkId ?? null,
});
useEffect(() => {
if (forkId) {
chatHook.setActiveId(forkId);
setInputText('');
}
}, [forkId]);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [chatHook.messages, chatHook.isStreaming]);
const handleSend = () => {
const trimmed = inputText.trim();
if (!trimmed || chatHook.isStreaming) return;
chatHook.sendMessage(trimmed);
setInputText('');
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleMerge = async () => {
if (!forkId || merging) return;
setMerging(true);
try {
await onMerge();
} finally {
setMerging(false);
}
};
const isOpen = forkId !== null;
return (
<aside className={`fork-panel ${isOpen ? 'open' : ''}`}>
{isOpen && (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: 280,
}}
>
{/* 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>
{/* 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 && <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>
</div>
</div>
)}
</aside>
);
}

View File

@@ -0,0 +1,163 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { MessageSquare, GitBranch, X, Maximize2 } from 'lucide-react';
import MessageBubble from './MessageBubble';
import ModelSelector from './ModelSelector';
function CoordinatePlane({ data }) {
try {
const config = JSON.parse(data);
const { width = 260, height = 200, points = [], segments = [], grid = [-6, 6, -6, 6] } = config;
const pad = 30;
const [xMin, xMax, yMin, yMax] = grid;
const sx = (x) => pad + ((x - xMin) / (xMax - xMin)) * (width - 2 * pad);
const sy = (y) => height - pad - ((y - yMin) / (yMax - yMin)) * (height - 2 * pad);
const gridLines = [];
for (let i = Math.ceil(xMin); i <= xMax; i++) gridLines.push(<line key={`gx${i}`} x1={sx(i)} y1={pad} x2={sx(i)} y2={height - pad} stroke="var(--border)" strokeWidth="0.5" />);
for (let i = Math.ceil(yMin); i <= yMax; i++) gridLines.push(<line key={`gy${i}`} x1={pad} y1={sy(i)} x2={width - pad} y2={sy(i)} stroke="var(--border)" strokeWidth="0.5" />);
return (
<svg width={width} height={height} style={{ display: 'block', margin: '0 auto', background: 'var(--bg-base)', borderRadius: 'var(--radius-md)' }}>
{gridLines}
<line x1={sx(xMin)} y1={sy(0)} x2={sx(xMax)} y2={sy(0)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
<line x1={sx(0)} y1={sy(yMin)} x2={sx(0)} y2={sy(yMax)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
{segments.map((seg, i) => <line key={`s${i}`} x1={sx(seg[0])} y1={sy(seg[1])} x2={sx(seg[2])} y2={sy(seg[3])} stroke="var(--accent-info)" strokeWidth="2" />)}
{points.map((pt, i) => (
<g key={`p${i}`}>
<circle cx={sx(pt[0])} cy={sy(pt[1])} r="4" fill="var(--accent-info)" />
<text x={sx(pt[0]) + 6} y={sy(pt[1]) - 6} fill="var(--text-primary)" fontSize="10" fontFamily="var(--font-mono)">({pt[0]},{pt[1]})</text>
</g>
))}
{sx(0) >= pad && sx(0) <= width - pad && sy(0) >= pad && sy(0) <= height - pad && (
<text x={sx(0) + 5} y={sy(0) - 5} fill="var(--text-tertiary)" fontSize="10">O</text>
)}
</svg>
);
} 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">
<MessageSquare size={28} className="chat-empty-state-icon" />
<div>
{hasConversation
? 'Escribe un mensaje para comenzar'
: 'Seleccioná una conversación del sidebar o creá una nueva'}
</div>
</div>
);
}
function GraphPanel({ messages, onClose }) {
const graphs = useMemo(() => {
const result = [];
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 { JSON.parse(match[1]); result.push({ data: match[1], key: msg.id + '-' + result.length }); } catch {}
}
}
return result;
}, [messages]);
if (graphs.length === 0) return null;
return (
<div style={{ width: 360, flexShrink: 0, borderLeft: '1px solid var(--border)', background: 'var(--bg-surface)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'rgba(129,140,248,0.06)' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--accent-info)', display: 'flex', alignItems: 'center', gap: 6 }}>📐 Gráficos ({graphs.length})</span>
<button onClick={onClose} className="icon-btn"><X size={14} /></button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{graphs.map((g) => (
<div key={g.key} style={{ marginBottom: 12, background: 'var(--bg-base)', borderRadius: 'var(--radius-md)', padding: 10, border: '1px solid var(--border)' }}>
<CoordinatePlane data={g.data} />
</div>
))}
</div>
</div>
);
}
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,
}) {
const messagesEndRef = useRef(null);
const containerRef = useRef(null);
const [graphPanelOpen, setGraphPanelOpen] = useState(false);
const handleForkFromMessage = (topic) => { if (onFork) onFork(topic); };
const hasGraphs = useMemo(() => {
return messages.some(m => m.role === 'assistant' && (m.content || '').includes('```graph'));
}, [messages]);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isStreaming]);
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)' }}>
{/* 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>
</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} />
{hasGraphs && (
<button onClick={() => setGraphPanelOpen(!graphPanelOpen)}
style={{ background: graphPanelOpen ? 'var(--accent-info)' : 'transparent', border: graphPanelOpen ? 'none' : '1px solid var(--border)', color: graphPanelOpen ? '#fff' : 'var(--text-secondary)', borderRadius: 'var(--radius-sm)', padding: '4px 10px', fontSize: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontWeight: 500 }}>
<Maximize2 size={14} /> Gráficos
</button>
)}
</div>
</div>
{/* Message list */}
<div ref={containerRef} style={{ flex: 1, overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{messages.length === 0 && <EmptyState hasConversation={!!conversation} />}
{messages.map((msg) => (
<MessageBubbleComponent key={msg.id ?? msg.created_at} message={msg} onFork={msg.role === 'assistant' ? handleForkFromMessage : undefined} />
))}
{isStreaming && <StreamingIndicator />}
<div ref={messagesEndRef} />
</div>
</div>
{graphPanelOpen && hasGraphs && <GraphPanel messages={messages} onClose={() => setGraphPanelOpen(false)} />}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import React, { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { GitMerge, GitBranch } from 'lucide-react';
import katex from 'katex';
function LatexRenderer({ text }) {
const parts = useMemo(() => {
const result = [];
const regex = /(\$\$[\s\S]*?\$\$|\$[^$\n]+?\$)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) result.push({ type: 'text', content: text.slice(lastIndex, match.index) });
const latex = match[0];
const isBlock = latex.startsWith('$$');
const formula = latex.slice(isBlock ? 2 : 1, latex.length - (isBlock ? 2 : 1));
try {
const html = katex.renderToString(formula, { throwOnError: false, displayMode: isBlock });
result.push({ type: 'latex', html, isBlock });
} catch { result.push({ type: 'text', content: latex }); }
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) result.push({ type: 'text', content: text.slice(lastIndex) });
return result;
}, [text]);
return parts.map((part, i) =>
part.type === 'latex' ? (
<span key={i} dangerouslySetInnerHTML={{ __html: part.html }}
style={part.isBlock ? { display: 'block', margin: '12px 0', textAlign: 'center' } : {}} />
) : <React.Fragment key={i}>{part.content}</React.Fragment>
);
}
function CoordinatePlane({ data }) {
try {
const config = JSON.parse(data);
const { width = 260, height = 200, points = [], segments = [], grid = [-6, 6, -6, 6] } = config;
const pad = 30;
const [xMin, xMax, yMin, yMax] = grid;
const sx = (x) => pad + ((x - xMin) / (xMax - xMin)) * (width - 2 * pad);
const sy = (y) => height - pad - ((y - yMin) / (yMax - yMin)) * (height - 2 * pad);
const gridLines = [];
for (let i = Math.ceil(xMin); i <= xMax; i++) gridLines.push(<line key={`gx${i}`} x1={sx(i)} y1={pad} x2={sx(i)} y2={height - pad} stroke="var(--border)" strokeWidth="0.5" />);
for (let i = Math.ceil(yMin); i <= yMax; i++) gridLines.push(<line key={`gy${i}`} x1={pad} y1={sy(i)} x2={width - pad} y2={sy(i)} stroke="var(--border)" strokeWidth="0.5" />);
const origin = { x: sx(0), y: sy(0) };
return (
<div style={{ margin: '12px 0', display: 'flex', justifyContent: 'center' }}>
<svg width={width} height={height} style={{ background: 'var(--bg-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
{gridLines}
<line x1={sx(xMin)} y1={sy(0)} x2={sx(xMax)} y2={sy(0)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
<line x1={sx(0)} y1={sy(yMin)} x2={sx(0)} y2={sy(yMax)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
<polygon points={`${sx(xMax)-5},${sy(0)-3} ${sx(xMax)},${sy(0)} ${sx(xMax)-5},${sy(0)+3}`} fill="var(--text-tertiary)" />
<polygon points={`${sx(0)-3},${sy(yMin)+5} ${sx(0)},${sy(yMin)} ${sx(0)+3},${sy(yMin)+5}`} fill="var(--text-tertiary)" />
{segments.map((seg, i) => <line key={`seg${i}`} x1={sx(seg[0])} y1={sy(seg[1])} x2={sx(seg[2])} y2={sy(seg[3])} stroke="var(--accent-green)" strokeWidth="2" />)}
{points.map((pt, i) => (
<g key={`pt${i}`}>
<circle cx={sx(pt[0])} cy={sy(pt[1])} r="4" fill="var(--accent-green)" />
<text x={sx(pt[0]) + 6} y={sy(pt[1]) - 6} fill="var(--text-primary)" fontSize="10" fontFamily="var(--font-mono)">
({pt[0]},{pt[1]})
</text>
</g>
))}
{origin.x >= pad && origin.x <= width - pad && origin.y >= pad && origin.y <= height - pad && (
<text x={origin.x + 5} y={origin.y - 5} fill="var(--text-tertiary)" fontSize="10">O</text>
)}
</svg>
</div>
);
} catch { return <div style={{ color: 'var(--accent-coral)', fontSize: 12 }}>Error al renderizar gráfico</div>; }
}
function MarkdownContent({ content }) {
return (
<div className="markdown-body">
<ReactMarkdown
components={{
code({ node, className, children, ...props }) {
const code = String(children).trim();
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>;
},
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>;
},
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>; },
h3({ children }) { return <h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 6, marginTop: 4 }}>{children}</h3>; },
li({ children }) { return <li style={{ marginBottom: 4 }}>{children}</li>; },
ul({ children }) { return <ul style={{ paddingLeft: 18, marginBottom: 8 }}>{children}</ul>; },
ol({ children }) { return <ol style={{ paddingLeft: 18, marginBottom: 8 }}>{children}</ol>; },
blockquote({ children }) { return <blockquote style={{ borderLeft: '3px solid var(--accent-green)', paddingLeft: 12, paddingRight: 8, paddingTop: 6, paddingBottom: 6, marginBottom: 8, background: 'rgba(74,222,128,0.05)', borderRadius: '0 var(--radius-sm) var(--radius-sm) 0' }}>{children}</blockquote>; },
a({ children, href }) { return <a href={href} target="_blank" rel="noreferrer">{children}</a>; },
}}
>
{content || ''}
</ReactMarkdown>
</div>
);
}
export default function MessageBubble({ message, onFork }) {
const { role, content, created_at } = message;
if (role === 'system') return null;
if (!content) return null;
if (role === 'context_merge') {
return (
<div className="context-merge-badge">
<GitMerge size={14} style={{ color: 'var(--accent-green)', flexShrink: 0 }} />
<span style={{ lineHeight: 1.4 }}>{content}</span>
</div>
);
}
const isUser = role === 'user';
return (
<div className="message-bubble" style={{ display: 'flex', justifyContent: isUser ? 'flex-end' : 'flex-start', width: '100%' }}>
<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 && (
<button
onClick={() => {
const topic = (content || '').split('\n')[0].replace(/^#+\s*/, '').substring(0, 50) || 'Estudio';
onFork(topic);
}}
title="Abrir fork sobre este tema"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'rgba(192,132,252,0.08)', border: '1px solid rgba(192,132,252,0.2)',
color: 'var(--accent-purple)', borderRadius: 'var(--radius-sm)', padding: '3px 8px',
fontSize: 10, cursor: 'pointer', marginTop: 6, fontWeight: 500,
transition: 'all var(--transition-fast)',
}}
>
<GitBranch size={11} /> Fork
</button>
)}
{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>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown, Check } from 'lucide-react';
export default function ModelSelector({ selectedModelId, onSelect, models }) {
const [open, setOpen] = useState(false);
const containerRef = useRef(null);
const selected = models.find((m) => m.id === selectedModelId) || models[0] || null;
useEffect(() => {
function handleClickOutside(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (model) => {
onSelect(model.id);
setOpen(false);
};
return (
<div className="model-selector" ref={containerRef}>
<button
onClick={() => setOpen((o) => !o)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
borderRadius: 'var(--radius-md)',
padding: '6px 10px',
fontSize: 12,
fontFamily: 'var(--font-ui)',
cursor: 'pointer',
outline: 'none',
transition: 'border-color var(--transition-fast), box-shadow var(--transition-fast)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--text-tertiary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)';
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-green)';
e.currentTarget.style.boxShadow = '0 0 0 1px rgba(59, 109, 17, 0.2)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{selected && (
<span
className={`provider-badge ${selected.provider}`}
style={{ fontSize: 9 }}
>
{selected.provider}
</span>
)}
<span style={{ fontWeight: 500 }}>
{selected?.name || 'Select model'}
</span>
<ChevronDown size={12} style={{ color: 'var(--text-tertiary)' }} />
</button>
{open && (
<div className="model-selector-dropdown">
{models.map((model) => (
<div
key={model.id}
className={`model-selector-item ${model.id === selectedModelId ? 'active' : ''}`}
onClick={() => handleSelect(model)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`provider-badge ${model.provider}`}>
{model.provider}
</span>
<span
style={{
fontSize: 12,
fontWeight: 500,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{model.name}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2 }}>
{model.is_default_main && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
<span className="role-dot main" />
main
</span>
)}
{model.is_default_fork && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
<span className="role-dot fork" />
fork
</span>
)}
{model.is_default_exam && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
<span className="role-dot exam" />
exam
</span>
)}
</div>
</div>
{model.id === selectedModelId && (
<Check size={14} style={{ color: 'var(--accent-green)', flexShrink: 0 }} />
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import React, { useState, useRef, useCallback } from 'react';
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors,
} from '@dnd-kit/core';
import {
arrayMove, SortableContext, verticalListSortingStrategy, useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
Settings, GripVertical, Trash2, FileText, Plus, StickyNote, Search, ChevronDown, ChevronRight,
} from 'lucide-react';
function DragOverlayItem({ pdf }) {
return (
<div className="dnd-drag-overlay">
<GripVertical size={14} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pdf.original_name}</span>
</div>
);
}
function SortablePdfItem({ pdf, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pdf.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
return (
<div ref={setNodeRef} style={style} className="sidebar-pdf-item">
<button className="sidebar-drag-handle" {...attributes} {...listeners}><GripVertical size={14} /></button>
<span className="sidebar-pdf-name" title={pdf.original_name}>{pdf.original_name}</span>
<button className="sidebar-pdf-delete" onClick={() => onDelete(pdf.id)} title="Eliminar"><Trash2 size={12} /></button>
</div>
);
}
export default function Sidebar({
collapsed, onToggle, pdfs, progress, conversations, activeConversation, notes,
onSelectConversation, onNewConversation, onUploadPdf, onReorderPdf, onDeletePdf,
onResetTopic, onNavigateSettings,
}) {
const fileInputRef = useRef(null);
const [pdfItems, setPdfItems] = useState(() => pdfs);
const [pdfSearch, setPdfSearch] = useState('');
const [pdfsExpanded, setPdfsExpanded] = useState(true);
const [progressExpanded, setProgressExpanded] = useState(true);
const [chatsExpanded, setChatsExpanded] = useState(true);
const [notesExpanded, setNotesExpanded] = useState(true);
React.useEffect(() => { setPdfItems(pdfs); }, [pdfs]);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = pdfItems.findIndex(p => p.id === active.id);
const newIndex = pdfItems.findIndex(p => p.id === over.id);
setPdfItems(arrayMove(pdfItems, oldIndex, newIndex));
onReorderPdf(active.id, newIndex);
};
const handleFileChange = (e) => { const f = e.target.files?.[0]; if (f) onUploadPdf(f); e.target.value = ''; };
const filteredPdfs = pdfSearch ? pdfItems.filter(p => p.original_name.toLowerCase().includes(pdfSearch.toLowerCase())) : pdfItems;
const SectionHeader = ({ icon, label, expanded, onToggle, extra }) => (
<div className="sidebar-section-header" onClick={onToggle} style={{ cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
<span>{icon} {label}</span>
</div>
{extra}
</div>
);
if (collapsed) {
return (
<aside className="app-sidebar" style={{ width: 48, alignItems: 'center', paddingTop: 12 }}>
<button onClick={onToggle} title="Expandir" className="sidebar-collapsed-btn"><FileText size={20} /></button>
</aside>
);
}
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>
<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>
</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>
</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.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>
))}
{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>
);
}

187
client/src/hooks/useChat.js Normal file
View File

@@ -0,0 +1,187 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { getMessages, postChatStream, streamSSE, updateProgress } from '../lib/api';
function extractExerciseJson(text) {
const fenceRegex = /```json\s*([\s\S]*?)\s*```/;
const match = text.match(fenceRegex);
if (match) {
try {
const parsed = JSON.parse(match[1]);
if (parsed && parsed.exercise_logged) {
return { exercise: parsed.exercise_logged, raw: match[0] };
}
} catch {
// ignore
}
}
const inlineRegex = /\{[^{}]*"exercise_logged"[^{}]*\}/g;
const inlineMatches = [...text.matchAll(inlineRegex)];
for (const inlineMatch of inlineMatches) {
try {
const parsed = JSON.parse(inlineMatch[0]);
if (parsed && parsed.exercise_logged) {
return { exercise: parsed.exercise_logged, raw: inlineMatch[0] };
}
} catch {
// ignore
}
}
return null;
}
export default function useChat({ conversationId, onProgressUpdate }) {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const [activeId, setActiveIdState] = useState(conversationId);
const abortRef = useRef(null);
const activeIdRef = useRef(activeId);
// Sync activeIdRef with current activeId
useEffect(() => {
activeIdRef.current = activeId;
}, [activeId]);
// Sync activeId with conversationId prop
useEffect(() => {
if (conversationId && conversationId !== activeId) {
setActiveIdState(conversationId);
}
}, [conversationId]);
// Abort any in-flight stream when conversation changes
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, [conversationId]);
const setActiveId = useCallback(async (id) => {
setActiveIdState(id);
if (!id) {
setMessages([]);
return;
}
try {
const data = await getMessages(id);
setMessages(data.messages || []);
} catch (err) {
console.error('[useChat] load messages error:', err.message);
setMessages([]);
}
}, []);
const sendMessage = useCallback(
async (text, pdfIds = [], attachments = []) => {
if (isStreaming) return;
if (!activeId || !text.trim()) return;
const controller = new AbortController();
abortRef.current = controller;
const userMsg = {
id: `temp-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setIsStreaming(true);
const assistantMsg = {
id: `temp-assist-${Date.now()}`,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMsg]);
try {
const response = await postChatStream({
conversation_id: activeId,
message: text,
pdf_ids: pdfIds,
attachment_texts: attachments,
}, controller.signal);
let fullText = '';
for await (const event of streamSSE(response)) {
if (controller.signal.aborted) break;
if (event.type === 'token') {
fullText += event.token;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsg.id ? { ...m, content: fullText } : m
)
);
} else if (event.type === 'done') {
fullText = event.full_text || fullText;
} else if (event.error) {
console.error('[useChat] stream error:', event.error);
break;
}
}
// Exercise JSON parsing after streaming
const extracted = extractExerciseJson(fullText);
if (extracted && extracted.exercise) {
const { topic, correct } = extracted.exercise;
if (topic !== undefined) {
try {
await updateProgress(topic, correct === true);
if (onProgressUpdate) onProgressUpdate(topic, correct === true);
} catch (err) {
console.error('[useChat] progress update error:', err.message);
}
}
// Remove the JSON from displayed message
let cleanText = fullText;
if (extracted.raw) {
cleanText = fullText.replace(extracted.raw, '').trim();
}
fullText = cleanText;
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsg.id ? { ...m, content: fullText } : m
)
);
} catch (err) {
if (err.name === 'AbortError') return;
console.error('[useChat] send error:', err.message);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsg.id
? { ...m, content: `Error: ${err.message}` }
: m
)
);
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
}
setIsStreaming(false);
if (controller.signal.aborted) return;
// Refresh messages from server to get persisted IDs only for current conversation
if (activeIdRef.current === activeId) {
try {
const data = await getMessages(activeId);
setMessages(data.messages || []);
} catch {
// silent
}
}
}
},
[activeId, onProgressUpdate, isStreaming]
);
return {
messages,
isStreaming,
activeId,
setActiveId,
sendMessage,
};
}

View File

@@ -0,0 +1,74 @@
import { useState, useCallback } from 'react';
import {
getPdfs,
uploadPdf as apiUploadPdf,
reorderPdf as apiReorderPdf,
deletePdf as apiDeletePdf,
} from '../lib/api';
export default function usePdfs() {
const [pdfs, setPdfs] = useState([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const rows = await getPdfs();
setPdfs(rows);
} catch (err) {
console.error('[usePdfs] refresh error:', err.message);
} finally {
setLoading(false);
}
}, []);
// Load once on mount is left to the consumer (App.jsx loads via effect if needed)
const uploadPdf = useCallback(
async (file) => {
setLoading(true);
try {
await apiUploadPdf(file);
const rows = await getPdfs();
setPdfs(rows);
} catch (err) {
console.error('[usePdfs] upload error:', err.message);
} finally {
setLoading(false);
}
},
[]
);
const reorderPdf = useCallback(
async (id, newIndex) => {
try {
await apiReorderPdf(id, newIndex);
await refresh();
} catch (err) {
console.error('[usePdfs] reorder error:', err.message);
}
},
[refresh]
);
const deletePdf = useCallback(
async (id) => {
try {
await apiDeletePdf(id);
await refresh();
} catch (err) {
console.error('[usePdfs] delete error:', err.message);
}
},
[refresh]
);
return {
pdfs,
loading,
refresh,
uploadPdf,
reorderPdf,
deletePdf,
};
}

View File

@@ -0,0 +1,74 @@
import { useState, useCallback, useEffect } from 'react';
import { getProgress, updateProgress, resetProgressTopic } from '../lib/api';
function calcPercentage(row) {
if (!row || row.exercises_done === 0) return 0;
return Math.round((row.exercises_correct / row.exercises_done) * 100);
}
export default function useProgress() {
const [progress, setProgress] = useState([]);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
try {
const rows = await getProgress();
setProgress(
rows.map((r) => ({
...r,
percentage: calcPercentage(r),
}))
);
setError(null);
} catch (err) {
console.error('[useProgress] refresh error:', err.message);
setError(err.message);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const updateExercise = useCallback(
async (topic, correct) => {
try {
const row = await updateProgress(topic, correct);
setProgress((prev) => {
const idx = prev.findIndex((p) => p.topic === topic);
const enriched = { ...row, percentage: calcPercentage(row) };
if (idx >= 0) {
const next = [...prev];
next[idx] = enriched;
return next;
}
return [...prev, enriched];
});
setError(null);
} catch (err) {
console.error('[useProgress] update error:', err.message);
setError(err.message);
}
},
[]
);
const resetTopic = useCallback(async (topic) => {
try {
await resetProgressTopic(topic);
setProgress((prev) => prev.filter((p) => p.topic !== topic));
setError(null);
} catch (err) {
console.error('[useProgress] reset error:', err.message);
setError(err.message);
}
}, []);
return {
progress,
error,
refresh,
updateExercise,
resetTopic,
};
}

68
client/src/index.css Normal file
View File

@@ -0,0 +1,68 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-base: #0f1117;
--bg-surface: #161822;
--bg-elevated: #1e2030;
--bg-glass: rgba(99,102,241,0.04);
--border: #2a2d3e;
--border-glow: #3d4060;
--text-primary: #e8e6f0;
--text-secondary: #a09db6;
--text-tertiary: #6b6880;
--accent-green: #34d399;
--accent-amber: #fbbf24;
--accent-coral: #f87171;
--accent-info: #818cf8;
--accent-purple: #c084fc;
--accent-pink: #f472b6;
--accent-cyan: #22d3ee;
--accent-orange: #fb923c;
--font-mono: 'Geist Mono', 'JetBrains Mono', monospace;
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--radius-pill: 9999px;
--transition-fast: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2);
--shadow-md: 0 4px 14px rgba(0,0,0,0.3);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.4);
--shadow-glow-green: 0 0 20px rgba(52,211,153,0.15);
--shadow-glow-purple: 0 0 24px rgba(192,132,252,0.12);
--shadow-glow-blue: 0 0 20px rgba(129,140,248,0.12);
}
html, body, #root { height: 100%; }
html { scroll-behavior: smooth; }
body {
background: linear-gradient(135deg, #0f1117 0%, #13152a 30%, #0f172a 60%, #0f1117 100%);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
overflow: hidden;
}
::selection { background: rgba(129,140,248,0.35); color: #fff; }
:focus-visible { outline: 2px solid var(--accent-info); outline-offset: 2px; border-radius: 4px; }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-pill); }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
code, pre { font-family: var(--font-mono); }
@keyframes pulse { 0%,100%{opacity:0.4;transform:scale(0.9)} 50%{opacity:1;transform:scale(1.1)} }
@keyframes spin { from{transform:rotate(0deg)} to{transform:rotate(360deg)} }
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
@keyframes slideUp { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
@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} }

312
client/src/lib/api.js Normal file
View File

@@ -0,0 +1,312 @@
const API_BASE = '/api';
async function handleResponse(res) {
if (!res.ok) {
const text = await res.text().catch(() => '');
let message = `HTTP ${res.status}`;
try {
const json = JSON.parse(text);
if (json.error) message = json.error;
} catch {
if (text) message = text;
}
throw new Error(message);
}
if (res.status === 204) return null;
return res.json();
}
export async function getPdfs() {
try {
const res = await fetch(`${API_BASE}/pdfs`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch PDFs: ${err.message}`);
}
}
export async function uploadPdf(file) {
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API_BASE}/pdfs/upload`, {
method: 'POST',
body: formData,
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to upload PDF: ${err.message}`);
}
}
export async function deletePdf(id) {
try {
const res = await fetch(`${API_BASE}/pdfs/${id}`, { method: 'DELETE' });
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to delete PDF: ${err.message}`);
}
}
export async function reorderPdf(id, reorderIndex) {
try {
const res = await fetch(`${API_BASE}/pdfs/${id}/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reorder_index: reorderIndex }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to reorder PDF: ${err.message}`);
}
}
export async function getConversations() {
try {
const res = await fetch(`${API_BASE}/conversations`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch conversations: ${err.message}`);
}
}
export async function createConversation(body) {
try {
const res = await fetch(`${API_BASE}/conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to create conversation: ${err.message}`);
}
}
export async function getMessages(convId) {
try {
const res = await fetch(`${API_BASE}/conversations/${convId}/messages`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch messages: ${err.message}`);
}
}
export async function forkConversation(id, body) {
try {
const res = await fetch(`${API_BASE}/conversations/${id}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fork conversation: ${err.message}`);
}
}
export async function mergeConversation(id) {
try {
const res = await fetch(`${API_BASE}/conversations/${id}/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to merge conversation: ${err.message}`);
}
}
export async function getModels() {
try {
const res = await fetch(`${API_BASE}/models`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch models: ${err.message}`);
}
}
export async function getProgress() {
try {
const res = await fetch(`${API_BASE}/progress`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch progress: ${err.message}`);
}
}
export async function updateProgress(topic, correct) {
try {
const res = await fetch(`${API_BASE}/progress/${encodeURIComponent(topic)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ correct }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to update progress: ${err.message}`);
}
}
export async function resetProgressTopic(topic) {
try {
const res = await fetch(`${API_BASE}/progress/${encodeURIComponent(topic)}`, {
method: 'DELETE',
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to reset progress: ${err.message}`);
}
}
export async function getNotes() {
try {
const res = await fetch(`${API_BASE}/notes`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch notes: ${err.message}`);
}
}
export async function getConfig() {
try {
const res = await fetch(`${API_BASE}/config`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch config: ${err.message}`);
}
}
export async function updateConfig(key, value) {
try {
const res = await fetch(`${API_BASE}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to update config: ${err.message}`);
}
}
export async function testVlm(url) {
try {
const res = await fetch(`${API_BASE}/config/test-vlm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to test VLM: ${err.message}`);
}
}
export async function createModel(body) {
try {
const res = await fetch(`${API_BASE}/models`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to create model: ${err.message}`);
}
}
export async function updateModel(id, body) {
try {
const res = await fetch(`${API_BASE}/models/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to update model: ${err.message}`);
}
}
export async function deleteModel(id) {
try {
const res = await fetch(`${API_BASE}/models/${id}`, { method: 'DELETE' });
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to delete model: ${err.message}`);
}
}
export async function testModel(id) {
try {
const res = await fetch(`${API_BASE}/models/${id}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to test model: ${err.message}`);
}
}
export async function* streamSSE(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
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);
if (data === '[DONE]') {
yield { type: 'done' };
return;
}
try {
yield JSON.parse(data);
} catch {
yield { type: 'raw', data };
}
}
}
}
if (buffer.startsWith('data: ')) {
const data = buffer.slice(6);
if (data === '[DONE]') {
yield { type: 'done' };
} else {
try {
yield JSON.parse(data);
} catch {
yield { type: 'raw', data };
}
}
}
} finally {
reader.releaseLock();
}
}
export async function postChatStream(body, signal) {
const res = await fetch(`${API_BASE}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Chat stream failed: ${res.status} ${text}`);
}
return res;
}

12
client/src/main.jsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
const root = createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<App />
</BrowserRouter>
);

View File

@@ -0,0 +1,745 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts';
import {
X,
Plus,
Trash2,
TestTube,
Loader2,
AlertTriangle,
} from 'lucide-react';
import {
getModels,
createModel,
updateModel,
deleteModel,
testModel,
getConfig,
updateConfig,
testVlm,
getPdfs,
deletePdf,
getProgress,
resetProgressTopic,
} from '../lib/api';
function ToggleSwitch({ checked, onChange }) {
return (
<button
className={`toggle-switch ${checked ? 'on' : ''}`}
onClick={() => onChange(!checked)}
aria-label="Toggle"
/>
);
}
function Modal({ title, onClose, children }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{title}</h3>
<button onClick={onClose} className="modal-close-btn">
<X size={16} />
</button>
</div>
{children}
</div>
</div>
);
}
export default function Settings() {
const [activeTab, setActiveTab] = useState('models');
// Models tab
const [models, setModels] = useState([]);
const [modelModalOpen, setModelModalOpen] = useState(false);
const [editingModel, setEditingModel] = useState(null);
const [modelForm, setModelForm] = useState({
name: '',
api_base: '',
api_key: '',
provider: 'openai',
is_default_main: false,
is_default_fork: false,
is_default_exam: false,
});
const [modelLoading, setModelLoading] = useState(false);
const [testResults, setTestResults] = useState({});
// PDFs & VLM tab
const [pdfs, setPdfs] = useState([]);
const [vlmUrl, setVlmUrl] = useState('');
const [vlmSaving, setVlmSaving] = useState(false);
const [vlmTestResult, setVlmTestResult] = useState(null);
// Progress tab
const [progress, setProgress] = useState([]);
const refreshModels = useCallback(async () => {
try {
const rows = await getModels();
setModels(rows);
} catch (err) {
console.error('[Settings] refresh models error:', err.message);
}
}, []);
const refreshPdfs = useCallback(async () => {
try {
const rows = await getPdfs();
setPdfs(rows);
} catch (err) {
console.error('[Settings] refresh pdfs error:', err.message);
}
}, []);
const refreshProgress = useCallback(async () => {
try {
const rows = await getProgress();
setProgress(rows);
} catch (err) {
console.error('[Settings] refresh progress error:', err.message);
}
}, []);
const loadConfig = useCallback(async () => {
try {
const cfg = await getConfig();
if (cfg.vlm_endpoint) {
setVlmUrl(cfg.vlm_endpoint);
}
} catch (err) {
console.error('[Settings] load config error:', err.message);
}
}, []);
useEffect(() => {
refreshModels();
refreshPdfs();
refreshProgress();
loadConfig();
}, []);
// Model handlers
const openAddModel = () => {
setEditingModel(null);
setModelForm({
name: '',
api_base: '',
api_key: '',
provider: 'openai',
is_default_main: false,
is_default_fork: false,
is_default_exam: false,
});
setModelModalOpen(true);
};
const openEditModel = (model) => {
setEditingModel(model);
setModelForm({
name: model.name || '',
api_base: model.api_base || '',
api_key: model.api_key || '',
provider: model.provider || 'openai',
is_default_main: !!model.is_default_main,
is_default_fork: !!model.is_default_fork,
is_default_exam: !!model.is_default_exam,
});
setModelModalOpen(true);
};
const handleSaveModel = async () => {
if (!modelForm.name || !modelForm.api_base) return;
setModelLoading(true);
try {
const body = {
name: modelForm.name,
api_base: modelForm.api_base,
api_key: modelForm.api_key,
provider: modelForm.provider,
is_default_main: modelForm.is_default_main,
is_default_fork: modelForm.is_default_fork,
is_default_exam: modelForm.is_default_exam,
};
if (editingModel) {
await updateModel(editingModel.id, body);
} else {
await createModel(body);
}
await refreshModels();
setModelModalOpen(false);
} catch (err) {
alert(err.message);
} finally {
setModelLoading(false);
}
};
const handleDeleteModel = async (id) => {
if (!window.confirm('¿Eliminar este modelo? No se puede recuperar.')) return;
try {
await deleteModel(id);
await refreshModels();
} catch (err) {
alert(err.message);
}
};
const handleTestModel = async (id) => {
setTestResults((prev) => ({ ...prev, [id]: { loading: true } }));
try {
const result = await testModel(id);
setTestResults((prev) => ({ ...prev, [id]: { loading: false, result } }));
} catch (err) {
setTestResults((prev) => ({ ...prev, [id]: { loading: false, error: err.message } }));
}
};
const handleToggleDefault = async (model, field) => {
const newValue = !model[field];
try {
await updateModel(model.id, { [field]: newValue });
await refreshModels();
} catch (err) {
alert(err.message);
}
};
// VLM handlers
const handleSaveVlm = async () => {
setVlmSaving(true);
try {
await updateConfig('vlm_endpoint', vlmUrl);
setVlmTestResult({ success: true, message: 'Guardado' });
} catch (err) {
setVlmTestResult({ success: false, message: err.message });
} finally {
setVlmSaving(false);
}
};
const handleTestVlm = async () => {
setVlmTestResult({ loading: true });
try {
const result = await testVlm(vlmUrl);
setVlmTestResult({
success: result.success,
message: result.success
? `${result.message} (${result.latency_ms}ms)`
: result.message,
});
} catch (err) {
setVlmTestResult({
success: false,
message: err.message,
});
}
};
const handleDeletePdf = async (id) => {
if (!window.confirm('¿Eliminar este PDF?')) return;
try {
await deletePdf(id);
await refreshPdfs();
} catch (err) {
alert(err.message);
}
};
// Progress handlers
const handleResetTopic = async (topic) => {
if (!window.confirm(`¿Resetear progreso de "${topic}"?`)) return;
try {
await resetProgressTopic(topic);
await refreshProgress();
} catch (err) {
alert(err.message);
}
};
const progressChartData = progress.map((p) => ({
name: p.topic,
pct: p.percentage,
fill:
p.percentage >= 80
? 'var(--accent-green)'
: p.percentage >= 50
? 'var(--accent-amber)'
: 'var(--accent-coral)',
}));
return (
<div className="settings-container">
<h1
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--text-primary)',
marginBottom: 24,
}}
>
Settings
</h1>
<div className="settings-tabs">
<button
className={`settings-tab ${activeTab === 'models' ? 'active' : ''}`}
onClick={() => setActiveTab('models')}
>
Modelos
</button>
<button
className={`settings-tab ${activeTab === 'pdfs' ? 'active' : ''}`}
onClick={() => setActiveTab('pdfs')}
>
PDFs & VLM
</button>
<button
className={`settings-tab ${activeTab === 'progress' ? 'active' : ''}`}
onClick={() => setActiveTab('progress')}
>
Progreso
</button>
</div>
{/* Tab 1: Modelos */}
{activeTab === 'models' && (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button className="btn btn-primary" onClick={openAddModel}>
<Plus size={14} style={{ marginRight: 6, verticalAlign: 'middle' }} />
Add Model
</button>
</div>
<table className="settings-table">
<thead>
<tr>
<th>Name</th>
<th>Provider</th>
<th>API Base</th>
<th style={{ textAlign: 'center' }}>Main</th>
<th style={{ textAlign: 'center' }}>Fork</th>
<th style={{ textAlign: 'center' }}>Exam</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr key={model.id}>
<td>
<span style={{ fontWeight: 500 }}>{model.name}</span>
</td>
<td>
<span className={`provider-badge ${model.provider}`}>
{model.provider}
</span>
</td>
<td>
<span style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
{model.api_base}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<ToggleSwitch
checked={!!model.is_default_main}
onChange={() => handleToggleDefault(model, 'is_default_main')}
/>
</td>
<td style={{ textAlign: 'center' }}>
<ToggleSwitch
checked={!!model.is_default_fork}
onChange={() => handleToggleDefault(model, 'is_default_fork')}
/>
</td>
<td style={{ textAlign: 'center' }}>
<ToggleSwitch
checked={!!model.is_default_exam}
onChange={() => handleToggleDefault(model, 'is_default_exam')}
/>
</td>
<td>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button
className="btn btn-sm btn-secondary"
onClick={() => handleTestModel(model.id)}
disabled={testResults[model.id]?.loading}
>
{testResults[model.id]?.loading ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<TestTube size={12} />
)}
Test
</button>
{testResults[model.id]?.result && (
<span style={{ fontSize: 11, color: 'var(--accent-green)' }}>
{testResults[model.id].result.latency_ms}ms
</span>
)}
{testResults[model.id]?.error && (
<span style={{ fontSize: 11, color: 'var(--accent-coral)' }}>
Error
</span>
)}
<button
className="btn btn-sm btn-secondary"
onClick={() => openEditModel(model)}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleDeleteModel(model.id)}
>
<Trash2 size={12} />
</button>
</div>
</td>
</tr>
))}
{models.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
No hay modelos configurados
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Tab 2: PDFs & VLM */}
{activeTab === 'pdfs' && (
<div>
<div style={{ marginBottom: 24 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
VLM Endpoint
</h2>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
value={vlmUrl}
onChange={(e) => setVlmUrl(e.target.value)}
placeholder="https://vlm.example.com/v1/chat"
className="vlm-input"
/>
<button className="btn btn-secondary" onClick={handleSaveVlm} disabled={vlmSaving}>
{vlmSaving ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
) : null}
Guardar
</button>
<button className="btn btn-secondary" onClick={handleTestVlm}>
Test VLM
</button>
</div>
{vlmTestResult && (
<div
style={{
marginTop: 8,
fontSize: 12,
color: vlmTestResult.loading ? 'var(--text-tertiary)' : vlmTestResult.success ? 'var(--accent-green)' : 'var(--accent-coral)',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
{vlmTestResult.loading ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : vlmTestResult.success ? (
<TestTube size={12} />
) : (
<AlertTriangle size={12} />
)}
{vlmTestResult.message}
</div>
)}
</div>
<div>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
PDFs cargados
</h2>
<table className="settings-table">
<thead>
<tr>
<th>Nombre</th>
<th>Orden</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{pdfs.map((pdf) => (
<tr key={pdf.id}>
<td>{pdf.filename}</td>
<td>{pdf.sort_order}</td>
<td>
<button
className="btn btn-sm btn-danger"
onClick={() => handleDeletePdf(pdf.id)}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
{pdfs.length === 0 && (
<tr>
<td colSpan={3} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
No hay PDFs cargados
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 3: Progreso */}
{activeTab === 'progress' && (
<div>
<div style={{ marginBottom: 24 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
Gráfico de progreso
</h2>
<div className="progress-chart-container">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={progressChartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<XAxis
dataKey="name"
tick={{ fill: 'var(--text-secondary)', fontSize: 11 }}
axisLine={{ stroke: 'var(--border)' }}
tickLine={false}
/>
<YAxis
tick={{ fill: 'var(--text-secondary)', fontSize: 11 }}
axisLine={false}
tickLine={false}
domain={[0, 100]}
unit="%"
/>
<Tooltip
contentStyle={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text-primary)',
fontSize: 12,
}}
itemStyle={{ color: 'var(--text-primary)' }}
formatter={(value) => [`${value}%`, 'Porcentaje']}
/>
<Bar dataKey="pct" radius={[4, 4, 0, 0]}>
{progressChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<table className="settings-table">
<thead>
<tr>
<th>Topic</th>
<th>Exercises Done</th>
<th>Exercises Correct</th>
<th>Percentage</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{progress.map((p) => (
<tr key={p.topic}>
<td>
<span style={{ fontWeight: 500 }}>{p.topic}</span>
</td>
<td>{p.exercises_done}</td>
<td>{p.exercises_correct}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="progress-bar-bg" style={{ width: 120 }}>
<div
className="progress-bar-fill"
style={{
width: `${p.percentage}%`,
background:
p.percentage >= 80
? 'var(--accent-green)'
: p.percentage >= 50
? 'var(--accent-amber)'
: 'var(--accent-coral)',
}}
/>
</div>
<span style={{ fontSize: 12, fontWeight: 600, minWidth: 32 }}>
{p.percentage}%
</span>
</div>
</td>
<td>
<button
className="btn btn-sm btn-danger"
onClick={() => handleResetTopic(p.topic)}
>
Reset
</button>
</td>
</tr>
))}
{progress.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
No hay progreso registrado
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Model Modal */}
{modelModalOpen && (
<Modal
title={editingModel ? 'Editar Modelo' : 'Agregar Modelo'}
onClose={() => setModelModalOpen(false)}
>
<div className="form-group">
<label>Nombre</label>
<input
value={modelForm.name}
onChange={(e) => setModelForm((f) => ({ ...f, name: e.target.value }))}
placeholder="gpt-4o"
/>
</div>
<div className="form-group">
<label>API Base</label>
<input
value={modelForm.api_base}
onChange={(e) => setModelForm((f) => ({ ...f, api_base: e.target.value }))}
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="form-group">
<label>API Key</label>
<input
type="password"
value={modelForm.api_key}
onChange={(e) => setModelForm((f) => ({ ...f, api_key: e.target.value }))}
placeholder="sk-..."
/>
</div>
<div className="form-group">
<label>Provider</label>
<select
value={modelForm.provider}
onChange={(e) => setModelForm((f) => ({ ...f, provider: e.target.value }))}
>
<option value="openai">openai</option>
<option value="anthropic">anthropic</option>
</select>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<ToggleSwitch
checked={modelForm.is_default_main}
onChange={(v) =>
setModelForm((f) => ({
...f,
is_default_main: v,
is_default_fork: v ? false : f.is_default_fork,
is_default_exam: v ? false : f.is_default_exam,
}))
}
/>
Default Main
<span className="role-dot main" style={{ marginLeft: 4 }} />
</label>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<ToggleSwitch
checked={modelForm.is_default_fork}
onChange={(v) =>
setModelForm((f) => ({
...f,
is_default_fork: v,
is_default_main: v ? false : f.is_default_main,
is_default_exam: v ? false : f.is_default_exam,
}))
}
/>
Default Fork
<span className="role-dot fork" style={{ marginLeft: 4 }} />
</label>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<ToggleSwitch
checked={modelForm.is_default_exam}
onChange={(v) =>
setModelForm((f) => ({
...f,
is_default_exam: v,
is_default_main: v ? false : f.is_default_main,
is_default_fork: v ? false : f.is_default_fork,
}))
}
/>
Default Exam
<span className="role-dot exam" style={{ marginLeft: 4 }} />
</label>
</div>
<div className="form-actions">
<button className="btn btn-secondary" onClick={() => setModelModalOpen(false)}>
Cancelar
</button>
<button className="btn btn-primary" onClick={handleSaveModel} disabled={modelLoading}>
{modelLoading ? (
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
) : null}
Guardar
</button>
</div>
</Modal>
)}
</div>
);
}

21
client/vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
ws: true,
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
proxyRes.setHeader('X-Accel-Buffering', 'no');
});
},
},
},
},
});

Binary file not shown.

Binary file not shown.

23
screen.js Normal file
View File

@@ -0,0 +1,23 @@
const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
const browser = await chromium.connectOverCDP('http://localhost:9222');
const contexts = browser.contexts();
const page = contexts[0].pages()[0];
await page.bringToFront();
await page.waitForTimeout(1000);
await page.screenshot({ path: 'C:/Users/Administrator/Desktop/studyos-screen.png', fullPage: false });
console.log('Screenshot saved');
const title = await page.title();
const html = await page.content();
console.log('Title:', title);
console.log('Has #root:', html.includes('id="root"'));
console.log('Has sidebar:', html.includes('sidebar'));
console.log('Has chat:', html.includes('mainchat'));
await browser.close();
})();

181
server/db.js Normal file
View File

@@ -0,0 +1,181 @@
const path = require('path');
const fs = require('fs');
const DATA_DIR = path.resolve(__dirname, '..', 'data');
const DB_PATH = path.join(DATA_DIR, 'studyos.db');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
let _sqlDb = null;
function saveToDisk() {
if (!_sqlDb) return;
const data = _sqlDb.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
}
function flatParams(params) {
if (params.length === 1 && Array.isArray(params[0])) return params[0];
return params;
}
function createWrapper(sqlDb) {
return {
prepare(sql) {
return {
run(...params) {
const p = flatParams(params);
sqlDb.run(sql, p);
const rowidResult = sqlDb.exec('SELECT last_insert_rowid()');
const lastInsertRowid = rowidResult.length > 0 ? rowidResult[0].values[0][0] : 0;
const changes = sqlDb.getRowsModified();
saveToDisk();
return { changes, lastInsertRowid };
},
get(...params) {
const p = flatParams(params);
let stmt = null;
try {
stmt = sqlDb.prepare(sql);
stmt.bind(p);
let result = null;
if (stmt.step()) result = stmt.getAsObject();
return result;
} finally {
if (stmt) stmt.free();
}
},
all(...params) {
const p = flatParams(params);
let stmt = null;
try {
stmt = sqlDb.prepare(sql);
stmt.bind(p);
const results = [];
while (stmt.step()) results.push(stmt.getAsObject());
return results;
} finally {
if (stmt) stmt.free();
}
},
};
},
exec(sql) {
sqlDb.exec(sql);
saveToDisk();
},
pragma(sql) {
sqlDb.exec('PRAGMA ' + sql + ';');
},
};
}
const db = createWrapper(null);
async function initDB() {
const SQL = await import('sql.js');
const initSqlJs = SQL.default;
const sqlModule = await initSqlJs();
let sqlDb;
if (fs.existsSync(DB_PATH)) {
const buffer = fs.readFileSync(DB_PATH);
sqlDb = new sqlModule.Database(buffer);
} else {
sqlDb = new sqlModule.Database();
}
_sqlDb = sqlDb;
const real = createWrapper(sqlDb);
db.prepare = real.prepare;
db.exec = real.exec;
db.pragma = real.pragma;
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
api_base TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL CHECK(provider IN ('openai', 'anthropic')),
is_default_main INTEGER NOT NULL DEFAULT 0,
is_default_fork INTEGER NOT NULL DEFAULT 0,
is_default_exam INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('main', 'fork')),
parent_id INTEGER REFERENCES conversations(id) ON DELETE SET NULL,
model_id INTEGER REFERENCES models(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'context_merge')),
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pdfs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
content_markdown TEXT NOT NULL DEFAULT '',
pages INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
reorder_index INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL UNIQUE,
exercises_done INTEGER NOT NULL DEFAULT 0,
exercises_correct INTEGER NOT NULL DEFAULT 0,
last_session TEXT,
notes TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content_markdown TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
`);
const modelCount = db.prepare('SELECT COUNT(*) as count FROM models').get();
if (!modelCount || modelCount.count === 0) {
db.prepare(`
INSERT INTO models (name, api_base, api_key, provider, is_default_main)
VALUES (?, ?, ?, ?, ?)
`).run('claude-sonnet-4', 'https://api.anthropic.com', '', 'anthropic', 1);
}
const vlmConfig = db.prepare("SELECT value FROM config WHERE key = ?").get('vlm_endpoint');
if (!vlmConfig) {
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('vlm_endpoint', 'http://localhost:8080/vlm');
}
return db;
}
module.exports = db;
module.exports.initDB = initDB;

71
server/index.js Normal file
View File

@@ -0,0 +1,71 @@
const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');
const cors = require('cors');
const path = require('path');
const db = require('./db');
const pdfRoutes = require('./routes/pdfs');
const conversationRoutes = require('./routes/conversations');
const chatRoutes = require('./routes/chat');
const progressRoutes = require('./routes/progress');
const notesRoutes = require('./routes/notes');
const modelRoutes = require('./routes/models');
const configRoutes = require('./routes/config');
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:3001'],
credentials: true,
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// API Routes
app.use('/api/pdfs', pdfRoutes);
app.use('/api/conversations', conversationRoutes);
app.use('/api/chat', chatRoutes);
app.use('/api/progress', progressRoutes);
app.use('/api/notes', notesRoutes);
app.use('/api/models', modelRoutes);
app.use('/api/config', configRoutes);
// Serve React build in production
const clientDist = path.resolve(__dirname, '..', 'client', 'dist');
app.use(express.static(clientDist));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api')) return next();
res.sendFile(path.join(clientDist, 'index.html'), (err) => {
if (err) next();
});
});
// WebSocket server
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
ws.on('message', (message) => {
// Echo — extensible for real-time notifications
});
try {
ws.send(JSON.stringify({ type: 'connected', message: 'StudyOS WebSocket connected' }));
} catch (err) {
console.error('[ws] send error:', err.message);
}
});
async function start() {
await db.initDB();
server.listen(PORT, () => {
console.log(`StudyOS server running on http://localhost:${PORT}`);
console.log(`WebSocket server running on ws://localhost:${PORT}`);
});
}
start().catch(err => {
console.error('Failed to start server:', err);
process.exit(1);
});

91
server/lib/llm.js Normal file
View File

@@ -0,0 +1,91 @@
const Anthropic = require('@anthropic-ai/sdk');
const OpenAI = require('openai');
/**
* Normalize Anthropic + OpenAI-compatible streams into one AsyncIterable.
* Yields: { token, done, fullText } events.
* Handles errors gracefully — emits error event, doesn't crash.
*
* Usage:
* for await (const chunk of streamCompletion(model, messages, systemPrompt)) {
* // chunk = { token: string, done: boolean, fullText: string }
* }
*/
async function* streamCompletion(model, messages, systemPrompt) {
if (!model || !model.provider) {
yield { token: '', done: true, fullText: '', error: 'Model or provider not specified' };
return;
}
try {
if (model.provider === 'anthropic') {
yield* streamAnthropic(model, messages, systemPrompt);
} else if (model.provider === 'openai') {
yield* streamOpenAI(model, messages, systemPrompt);
} else {
yield { token: '', done: true, fullText: '', error: `Unknown provider: ${model.provider}` };
}
} catch (err) {
console.error('[llm] streamCompletion error:', err.message);
yield { token: '', done: true, fullText: '', error: err.message };
}
}
async function* streamAnthropic(model, messages, systemPrompt) {
const client = new Anthropic({
apiKey: model.api_key || process.env.ANTHROPIC_API_KEY,
});
const stream = await client.messages.create({
model: model.name,
max_tokens: 4096,
system: systemPrompt || undefined,
messages: messages.map(m => ({ role: m.role, content: m.content })),
stream: true,
});
let fullText = '';
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
const token = event.delta.text || '';
fullText += token;
yield { token, done: false, fullText };
}
}
yield { token: '', done: true, fullText };
}
async function* streamOpenAI(model, messages, systemPrompt) {
const client = new OpenAI({
apiKey: model.api_key || process.env.OPENAI_API_KEY,
baseURL: model.api_base,
});
const openaiMessages = [];
if (systemPrompt) {
openaiMessages.push({ role: 'system', content: systemPrompt });
}
openaiMessages.push(...messages.map(m => ({ role: m.role, content: m.content })));
const stream = await client.chat.completions.create({
model: model.name,
messages: openaiMessages,
stream: true,
});
let fullText = '';
for await (const chunk of stream) {
const token = chunk.choices?.[0]?.delta?.content || '';
if (token) {
fullText += token;
yield { token, done: false, fullText };
}
}
yield { token: '', done: true, fullText };
}
module.exports = { streamCompletion };

1645
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
server/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "studyos-server",
"version": "1.0.0",
"description": "StudyOS backend server",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"multer": "^1.4.5-lts.1",
"openai": "^4.70.0",
"pdfjs-dist": "^4.4.168",
"playwright": "^1.60.0",
"sql.js": "^1.10.0",
"ws": "^8.18.0"
}
}

213
server/routes/chat.js Normal file
View File

@@ -0,0 +1,213 @@
const express = require('express');
const db = require('../db');
const { buildSystemPrompt } = require('../systemPromptBuilder');
const { streamCompletion } = require('../lib/llm');
const router = express.Router();
// POST /api/chat/stream — SSE streaming endpoint
router.post('/stream', async (req, res) => {
const { conversation_id, message, pdf_ids = [], attachment_texts = [] } = req.body;
if (!conversation_id || !message) {
return res.status(400).json({ error: 'conversation_id and message are required' });
}
const convId = parseInt(conversation_id, 10);
if (Number.isNaN(convId)) {
return res.status(400).json({ error: 'Invalid conversation_id' });
}
// Set SSE headers immediately
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 {
// 1. Fetch conversation + model + progress + PDF contents
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(convId);
if (!conv) {
sendEvent({ error: 'Conversation not found' });
res.end();
return;
}
let model = null;
if (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_main = 1 LIMIT 1').get();
}
if (!model) {
sendEvent({ error: 'No model configured' });
res.end();
return;
}
const progressRows = db.prepare('SELECT * FROM progress').all();
let pdfContents = [];
if (pdf_ids.length > 0) {
const validIds = pdf_ids.map(id => parseInt(id, 10)).filter(id => !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);
// 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++;
}
// Filter: skip duplicate consecutive users (keep only the last in sequence)
const existingMessages = [];
for (let i = 0; i < rawMessages.length; i++) {
const curr = rawMessages[i];
if (curr.role === 'user' && i + 1 < rawMessages.length && rawMessages[i + 1].role === 'user') {
db.prepare('DELETE FROM messages WHERE id = ?').run(curr.id);
continue;
}
existingMessages.push({ role: curr.role, content: curr.content });
}
const messages = [
...existingMessages.filter(m => m.role === 'user' || m.role === 'assistant'),
{ role: 'user', content: message },
];
// 5. Stream via llm.streamCompletion()
let assistantText = '';
let errorOccurred = false;
for await (const chunk of streamCompletion(model, messages, systemPrompt)) {
if (chunk.error) {
sendEvent({ error: chunk.error });
errorOccurred = true;
break;
}
if (chunk.token) {
assistantText += chunk.token;
sendEvent({ token: chunk.token });
}
if (chunk.done) {
assistantText = chunk.fullText;
}
}
if (errorOccurred) {
sendEvent({ done: true, full_text: '' });
res.end();
return;
}
// 6. Parse exercise_logged JSON from response
const exerciseLogs = [];
const rawMatches = [];
const fenceRegex = /```json\s*([\s\S]*?)\s*```/g;
const fenceMatches = [...assistantText.matchAll(fenceRegex)];
for (const fenceMatch of fenceMatches) {
try {
const parsed = JSON.parse(fenceMatch[1]);
if (parsed && parsed.exercise_logged) {
const entries = Array.isArray(parsed.exercise_logged)
? parsed.exercise_logged
: [parsed.exercise_logged];
for (const entry of entries) {
if (entry && entry.topic) exerciseLogs.push(entry);
}
rawMatches.push(fenceMatch[0]);
}
} catch (e) {
console.error('[chat] exercise JSON parse error:', e.message);
}
}
// Alternative: inline JSON without fence
const inlineRegex = /\{[^{}]*"exercise_logged"[^{}]*\}/g;
const inlineMatches = [...assistantText.matchAll(inlineRegex)];
for (const inlineMatch of inlineMatches) {
try {
const parsed = JSON.parse(inlineMatch[0]);
if (parsed && parsed.exercise_logged) {
const entries = Array.isArray(parsed.exercise_logged)
? parsed.exercise_logged
: [parsed.exercise_logged];
for (const entry of entries) {
if (entry && entry.topic) exerciseLogs.push(entry);
}
rawMatches.push(inlineMatch[0]);
}
} catch (e) {
// silent
}
}
// 7. Upsert progress table for each exercise
for (const exerciseLogged of exerciseLogs) {
const topic = exerciseLogged.topic;
const correct = exerciseLogged.correct === true ? 1 : 0;
const existing = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
if (existing) {
db.prepare(`
UPDATE progress SET
exercises_done = exercises_done + 1,
exercises_correct = exercises_correct + ?,
last_session = datetime('now'),
notes = ?
WHERE topic = ?
`).run(correct, existing.notes, topic);
} else {
db.prepare(`
INSERT INTO progress (topic, exercises_done, exercises_correct, last_session, notes)
VALUES (?, 1, ?, datetime('now'), '[]')
`).run(topic, correct);
}
}
// Strip JSON blocks from text before saving
let cleanText = assistantText;
for (const raw of rawMatches) {
cleanText = cleanText.replace(raw, '');
}
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 (?, ?, ?)')
.run(convId, 'assistant', cleanText);
// Update conversation updated_at
db.prepare("UPDATE conversations SET updated_at = datetime('now') WHERE id = ?").run(convId);
sendEvent({ done: true, full_text: cleanText });
res.end();
} catch (err) {
console.error('[chat] stream error:', err.message);
sendEvent({ error: err.message });
res.end();
}
});
module.exports = router;

66
server/routes/config.js Normal file
View File

@@ -0,0 +1,66 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
// GET /api/config — all key-value pairs
router.get('/', (req, res) => {
try {
const rows = db.prepare('SELECT key, value FROM config ORDER BY key').all();
const result = {};
for (const row of rows) {
result[row.key] = row.value;
}
res.json(result);
} catch (err) {
console.error('[config] list error:', err.message);
res.status(500).json({ error: err.message });
}
});
// PUT /api/config — upsert by key
// Body: { key, value }
router.put('/', (req, res) => {
const { key, value } = req.body;
if (key === undefined || value === undefined) {
return res.status(400).json({ error: 'key and value are required' });
}
try {
const existing = db.prepare('SELECT key FROM config WHERE key = ?').get(key);
if (existing) {
db.prepare('UPDATE config SET value = ? WHERE key = ?').run(value, key);
} else {
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run(key, value);
}
res.json({ key, value });
} catch (err) {
console.error('[config] upsert error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/config/test-vlm — proxy VLM test through backend to avoid CORS
router.post('/test-vlm', async (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'url is required' });
}
const start = Date.now();
try {
const resp = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) });
res.json({
success: resp.ok,
latency_ms: Date.now() - start,
message: resp.ok ? 'Endpoint responde' : `HTTP ${resp.status}`,
});
} catch (err) {
res.json({
success: false,
latency_ms: Date.now() - start,
message: err.message,
});
}
});
module.exports = router;

View File

@@ -0,0 +1,201 @@
const express = require('express');
const db = require('../db');
const { buildSystemPrompt } = require('../systemPromptBuilder');
const { streamCompletion } = require('../lib/llm');
const router = express.Router();
// POST /api/conversations — create main or fork
router.post('/', (req, res) => {
const { title, type = 'main', parent_id, model_id, topic } = req.body;
if (!title) {
return res.status(400).json({ error: 'title is required' });
}
try {
const info = db.prepare(`
INSERT INTO conversations (title, type, parent_id, model_id)
VALUES (?, ?, ?, ?)
`).run(title, type, parent_id || null, model_id || null);
const row = db.prepare('SELECT * FROM conversations WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json(row);
} catch (err) {
console.error('[conversations] create error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/conversations — list all, order by updated_at desc
router.get('/', (req, res) => {
try {
const rows = db.prepare('SELECT * FROM conversations ORDER BY updated_at DESC').all();
res.json(rows);
} catch (err) {
console.error('[conversations] list error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/conversations/:id/messages — chat history
router.get('/:id/messages', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid conversation id' });
}
try {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(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(id);
const forkPointRow = db.prepare('SELECT value FROM config WHERE key = ?').get(`fork_point_${id}`);
const forkPoint = forkPointRow ? parseInt(forkPointRow.value, 10) : undefined;
res.json({ conversation: { ...conv, fork_point: forkPoint }, messages });
} catch (err) {
console.error('[conversations] messages error:', err.message);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/conversations/:id
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid conversation id' });
}
try {
const info = db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Conversation not found' });
}
res.json({ deleted: true });
} catch (err) {
console.error('[conversations] delete error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/conversations/:id/fork — create child conversation with new topic+model, no message inheritance
router.post('/:id/fork', (req, res) => {
const parentId = parseInt(req.params.id, 10);
if (Number.isNaN(parentId)) {
return res.status(400).json({ error: 'Invalid conversation id' });
}
const { topic, model_id } = req.body;
if (!topic) {
return res.status(400).json({ error: 'topic is required' });
}
try {
const parent = db.prepare('SELECT * FROM conversations WHERE id = ?').get(parentId);
if (!parent) {
return res.status(404).json({ error: 'Parent conversation not found' });
}
// Get last message id as fork_point
const lastMsg = db.prepare(
'SELECT id FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT 1'
).get(parentId);
const forkPoint = lastMsg ? lastMsg.id : 0;
const info = db.prepare(`
INSERT INTO conversations (title, type, parent_id, model_id)
VALUES (?, ?, ?, ?)
`).run(topic, 'fork', parentId, model_id || parent.model_id || null);
const newId = info.lastInsertRowid;
// Persist fork_point in config table
db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
.run(`fork_point_${newId}`, String(forkPoint));
const row = db.prepare('SELECT * FROM conversations WHERE id = ?').get(newId);
res.status(201).json({ ...row, fork_point: forkPoint });
} catch (err) {
console.error('[conversations] fork error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/conversations/:id/merge — generate summary via LLM, save context_merge message in parent
router.post('/:id/merge', async (req, res) => {
const forkId = parseInt(req.params.id, 10);
if (Number.isNaN(forkId)) {
return res.status(400).json({ error: 'Invalid conversation id' });
}
try {
const fork = db.prepare('SELECT * FROM conversations WHERE id = ?').get(forkId);
if (!fork) {
return res.status(404).json({ error: 'Fork conversation not found' });
}
if (fork.type !== 'fork') {
return res.status(400).json({ error: 'Conversation is not a fork' });
}
const parentId = fork.parent_id;
if (!parentId) {
return res.status(400).json({ error: 'Fork has no parent' });
}
const forkMessages = db.prepare(
'SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id'
).all(forkId);
if (forkMessages.length === 0) {
return res.status(400).json({ error: 'Fork has no messages to merge' });
}
// Get model for summarization
let model = null;
if (fork.model_id) {
model = db.prepare('SELECT * FROM models WHERE id = ?').get(fork.model_id);
}
if (!model) {
model = db.prepare('SELECT * FROM models WHERE is_default_fork = 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 summarization' });
}
const transcript = forkMessages.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 }], '')) {
if (chunk.error) {
return res.status(502).json({ error: chunk.error });
}
if (chunk.done) {
summary = chunk.fullText;
}
}
// Max 200 tokens ~ 1500 chars
const truncated = summary.length > 1500 ? summary.slice(0, 1500) : summary;
// Insert context_merge into parent
const info = db.prepare(`
INSERT INTO messages (conversation_id, role, content)
VALUES (?, ?, ?)
`).run(parentId, 'context_merge', `[Resumen de fork: ${fork.title}]\n\n${truncated}`);
res.json({ parent_id: parentId, merged_message_id: info.lastInsertRowid, chars: truncated.length });
} catch (err) {
console.error('[conversations] merge error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

163
server/routes/models.js Normal file
View File

@@ -0,0 +1,163 @@
const express = require('express');
const db = require('../db');
const { streamCompletion } = require('../lib/llm');
const router = express.Router();
// GET /api/models — list all
router.get('/', (req, res) => {
try {
const rows = db.prepare('SELECT * FROM models ORDER BY id').all();
res.json(rows);
} catch (err) {
console.error('[models] list 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;
if (!name || !api_base || !provider) {
return res.status(400).json({ error: 'name, api_base, and provider are required' });
}
try {
const info = db.prepare(`
INSERT INTO models (name, api_base, api_key, provider, is_default_main, is_default_fork, is_default_exam)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
name,
api_base,
api_key || '',
provider,
is_default_main ? 1 : 0,
is_default_fork ? 1 : 0,
is_default_exam ? 1 : 0
);
// If setting a default flag, unset others for that role
const newId = info.lastInsertRowid;
if (is_default_main) unsetOtherDefaults(newId, 'is_default_main');
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);
res.status(201).json(row);
} catch (err) {
console.error('[models] create error:', err.message);
res.status(500).json({ error: err.message });
}
});
// PUT /api/models/:id — update
router.put('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid model id' });
}
const { name, api_base, api_key, provider, is_default_main, is_default_fork, is_default_exam } = req.body;
try {
const existing = db.prepare('SELECT * FROM models WHERE id = ?').get(id);
if (!existing) {
return res.status(404).json({ error: 'Model not found' });
}
db.prepare(`
UPDATE models SET
name = COALESCE(?, name),
api_base = COALESCE(?, api_base),
api_key = ?,
provider = COALESCE(?, provider),
is_default_main = COALESCE(?, is_default_main),
is_default_fork = COALESCE(?, is_default_fork),
is_default_exam = COALESCE(?, is_default_exam)
WHERE id = ?
`).run(
name ?? null,
api_base ?? null,
api_key !== undefined ? (api_key === null ? '' : api_key) : 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,
id
);
if (is_default_main) unsetOtherDefaults(id, 'is_default_main');
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);
res.json(row);
} catch (err) {
console.error('[models] update error:', err.message);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/models/:id
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid model id' });
}
try {
// Reject if conversations reference this model
const convCount = db.prepare('SELECT COUNT(*) as count FROM conversations WHERE model_id = ?').get(id);
if (convCount.count > 0) {
return res.status(409).json({ error: 'Cannot delete model referenced by conversations' });
}
const info = db.prepare('DELETE FROM models WHERE id = ?').run(id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Model not found' });
}
res.json({ deleted: true });
} catch (err) {
console.error('[models] delete error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/models/:id/test — send "di hola" and return latency
router.post('/:id/test', async (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid model id' });
}
try {
const model = db.prepare('SELECT * FROM models WHERE id = ?').get(id);
if (!model) {
return res.status(404).json({ error: 'Model not found' });
}
const start = Date.now();
let fullText = '';
for await (const chunk of streamCompletion(model, [{ role: 'user', content: 'di hola' }], '')) {
if (chunk.error) {
return res.status(502).json({ error: chunk.error });
}
if (chunk.done) {
fullText = chunk.fullText;
}
}
const latency = Date.now() - start;
res.json({ latency_ms: latency, response: fullText.trim() });
} catch (err) {
console.error('[models] test error:', err.message);
res.status(500).json({ error: err.message });
}
});
function unsetOtherDefaults(exceptId, column) {
db.prepare(`UPDATE models SET ${column} = 0 WHERE id != ?`).run(exceptId);
}
module.exports = router;

104
server/routes/notes.js Normal file
View File

@@ -0,0 +1,104 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
// GET /api/notes — list all
router.get('/', (req, res) => {
try {
const rows = db.prepare('SELECT * FROM notes ORDER BY updated_at DESC').all();
res.json(rows);
} catch (err) {
console.error('[notes] list error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/notes — create
router.post('/', (req, res) => {
const { title, content_markdown, tags = [] } = req.body;
if (!title) {
return res.status(400).json({ error: 'title is required' });
}
let tagsJson;
try {
tagsJson = JSON.stringify(Array.isArray(tags) ? tags : []);
} catch {
return res.status(400).json({ error: 'tags must be a valid array' });
}
try {
const info = db.prepare(`
INSERT INTO notes (title, content_markdown, tags)
VALUES (?, ?, ?)
`).run(title, content_markdown || '', tagsJson);
const row = db.prepare('SELECT * FROM notes WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json(row);
} catch (err) {
console.error('[notes] create error:', err.message);
res.status(500).json({ error: err.message });
}
});
// PUT /api/notes/:id — update (updated_at auto-set)
router.put('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid note id' });
}
const { title, content_markdown, tags } = req.body;
let tagsJson = undefined;
if (tags !== undefined) {
try {
tagsJson = JSON.stringify(Array.isArray(tags) ? tags : []);
} catch {
return res.status(400).json({ error: 'tags must be a valid array' });
}
}
try {
const existing = db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
if (!existing) {
return res.status(404).json({ error: 'Note not found' });
}
db.prepare(`
UPDATE notes SET
title = COALESCE(?, title),
content_markdown = COALESCE(?, content_markdown),
tags = COALESCE(?, tags),
updated_at = datetime('now')
WHERE id = ?
`).run(title ?? null, content_markdown ?? null, tagsJson ?? null, id);
const row = db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
res.json(row);
} catch (err) {
console.error('[notes] update error:', err.message);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/notes/:id
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid note id' });
}
try {
const info = db.prepare('DELETE FROM notes WHERE id = ?').run(id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.json({ deleted: true });
} catch (err) {
console.error('[notes] delete error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

211
server/routes/pdfs.js Normal file
View File

@@ -0,0 +1,211 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const db = require('../db');
const router = express.Router();
const uploadDir = path.resolve(__dirname, '..', '..', 'data', 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const unique = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, unique + path.extname(file.originalname));
},
});
const upload = multer({ storage, limits: { fileSize: 50 * 1024 * 1024 } }); // 50MB
async function extractPDFText(filePath) {
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;
const texts = [];
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map(item => item.str).join(' ');
texts.push(pageText);
}
return { text: texts.join('\n\n---\n\n'), pages: doc.numPages };
} catch (err) {
console.error('[pdfs] pdfjs extract error:', err.message);
return { text: '', pages: 0 };
}
}
async function vlmExtract(filePath, vlmConfig) {
try {
const buffer = fs.readFileSync(filePath);
const base64 = buffer.toString('base64');
const mimeType = 'application/pdf';
const resp = await fetch(`${vlmConfig.endpoint}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${vlmConfig.api_key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: vlmConfig.model || 'glm-4.6v',
messages: [{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64}` } },
{ type: 'text', text: 'Extract all text from this document as markdown. Preserve structure: headers, lists, paragraphs, tables. Be thorough.' },
],
}],
max_tokens: 4096,
}),
});
if (!resp.ok) {
const errText = await resp.text().catch(() => '');
throw new Error(`VLM HTTP ${resp.status}: ${errText.substring(0, 200)}`);
}
const data = await resp.json();
return data.choices?.[0]?.message?.content || data.text || data.markdown || '';
} catch (err) {
console.error('[pdfs] VLM error:', err.message);
return null;
}
}
// POST /api/pdfs/upload
router.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
try {
const filePath = req.file.path;
let text = '';
let pages = 0;
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();
if (vlmEndpoint?.value && vlmApiKey?.value) {
const vlmConfig = {
endpoint: vlmEndpoint.value,
api_key: vlmApiKey.value,
model: vlmModel?.value || 'glm-4.6v',
};
text = await vlmExtract(filePath, vlmConfig);
if (text) usedVlm = true;
}
// 2. Fallback to pdfjs-dist
if (!text || text.trim().length === 0) {
const extracted = await extractPDFText(filePath);
text = extracted.text;
pages = extracted.pages;
}
const maxReorder = db.prepare('SELECT MAX(reorder_index) as maxIdx FROM pdfs').get();
const reorderIndex = (maxReorder?.maxIdx ?? -1) + 1;
const info = db.prepare(`
INSERT INTO pdfs (filename, original_name, content_markdown, pages, reorder_index)
VALUES (?, ?, ?, ?, ?)
`).run(req.file.filename, req.file.originalname, text || '', pages || 0, reorderIndex);
const row = db.prepare('SELECT * FROM pdfs WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json({ ...row, used_vlm: usedVlm });
} catch (err) {
console.error('[pdfs] upload error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/pdfs — list all with metadata + reorder_index
router.get('/', (req, res) => {
try {
const rows = db.prepare('SELECT id, filename, original_name, content_markdown, created_at, reorder_index, pages FROM pdfs ORDER BY reorder_index, id').all();
res.json(rows);
} catch (err) {
console.error('[pdfs] list error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/pdfs/:id — full markdown
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid pdf id' });
}
try {
const row = db.prepare('SELECT * FROM pdfs WHERE id = ?').get(id);
if (!row) {
return res.status(404).json({ error: 'PDF not found' });
}
res.json(row);
} catch (err) {
console.error('[pdfs] get error:', err.message);
res.status(500).json({ error: err.message });
}
});
// PUT /api/pdfs/:id/reorder — update reorder_index
router.put('/:id/reorder', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid pdf id' });
}
const { reorder_index } = req.body;
if (reorder_index === undefined) {
return res.status(400).json({ error: 'reorder_index is required' });
}
try {
const info = db.prepare('UPDATE pdfs SET reorder_index = ? WHERE id = ?').run(reorder_index, id);
if (info.changes === 0) {
return res.status(404).json({ error: 'PDF not found' });
}
res.json({ id, reorder_index });
} catch (err) {
console.error('[pdfs] reorder error:', err.message);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/pdfs/:id
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid pdf id' });
}
try {
const row = db.prepare('SELECT filename FROM pdfs WHERE id = ?').get(id);
if (!row) {
return res.status(404).json({ error: 'PDF not found' });
}
// Delete file from disk
const filePath = path.join(uploadDir, row.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
db.prepare('DELETE FROM pdfs WHERE id = ?').run(id);
res.json({ deleted: true });
} catch (err) {
console.error('[pdfs] delete error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

78
server/routes/progress.js Normal file
View File

@@ -0,0 +1,78 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
// GET /api/progress — all topics with pct calculation
router.get('/', (req, res) => {
try {
const rows = db.prepare('SELECT * FROM progress ORDER BY topic').all();
const result = rows.map(r => {
const pct = r.exercises_done > 0 ? Math.round((r.exercises_correct / r.exercises_done) * 100) : 0;
return {
topic: r.topic,
exercises_done: r.exercises_done,
exercises_correct: r.exercises_correct,
percentage: pct,
last_session: r.last_session,
notes: r.notes,
};
});
res.json(result);
} catch (err) {
console.error('[progress] list error:', err.message);
res.status(500).json({ error: err.message });
}
});
// PUT /api/progress/:topic — update exercises (body: { correct: bool })
router.put('/:topic', (req, res) => {
const topic = req.params.topic;
const { correct } = req.body;
if (correct === undefined) {
return res.status(400).json({ error: 'correct is required' });
}
try {
const existing = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
if (existing) {
db.prepare(`
UPDATE progress SET
exercises_done = exercises_done + 1,
exercises_correct = exercises_correct + ?,
last_session = datetime('now')
WHERE topic = ?
`).run(correct === true ? 1 : 0, topic);
} else {
db.prepare(`
INSERT INTO progress (topic, exercises_done, exercises_correct, last_session, notes)
VALUES (?, 1, ?, datetime('now'), '[]')
`).run(topic, correct === true ? 1 : 0);
}
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 });
} catch (err) {
console.error('[progress] update error:', err.message);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/progress/:topic — reset
router.delete('/:topic', (req, res) => {
const topic = req.params.topic;
try {
const info = db.prepare('DELETE FROM progress WHERE topic = ?').run(topic);
if (info.changes === 0) {
return res.status(404).json({ error: 'Topic not found' });
}
res.json({ deleted: true });
} catch (err) {
console.error('[progress] delete error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,120 @@
/**
* 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 = []) {
if (conversation.type === 'main') {
return buildMainPrompt(progressRows, pdfContents, attachmentTexts);
}
if (conversation.type === 'fork') {
return buildForkPrompt(conversation);
}
return '';
}
function buildMainPrompt(progressRows, pdfContents, attachmentTexts) {
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:
${formatProgressRows(progressRows)}
REGLAS PARA PARCIALES SIMULADOS:
- Temas marcados como DOMINADO (>=80%): incluir máximo 1-2 ejercicios simples de repaso.
- Temas en progreso o sin práctica: incluir proporcionalmente más ejercicios.
`;
if (pdfContents.length > 0) {
prompt += `
PDFS DISPONIBLES (en orden de prioridad del usuario):
${formatPDFList(pdfContents)}
Cuando el usuario pida contenido de un PDF, incluir el markdown relevante en tu respuesta.
`;
const MAX_CONTENT_LENGTH = 30000;
let pdfContentBlocks = [];
let totalLength = 0;
for (const pdf of pdfContents) {
if (!pdf.content_markdown) continue;
const content = pdf.content_markdown;
totalLength += content.length;
pdfContentBlocks.push({ name: pdf.original_name, content });
}
if (totalLength > MAX_CONTENT_LENGTH) {
const ratio = MAX_CONTENT_LENGTH / totalLength;
pdfContentBlocks = pdfContentBlocks.map((b) => ({
name: b.name,
content:
b.content.substring(0, Math.floor(b.content.length * ratio)) +
'\n\n[Contenido truncado por límites de contexto]',
}));
}
if (pdfContentBlocks.length > 0) {
prompt += pdfContentBlocks
.map((b) => `\n--- CONTENIDO DE "${b.name}" ---\n${b.content}`)
.join('\n');
}
}
if (attachmentTexts.length > 0) {
prompt += `
ARCHIVOS ADJUNTOS EN ESTA CONSULTA:
${attachmentTexts.map((t, i) => `--- Adjunto ${i + 1} ---\n${t}`).join('\n\n')}
`;
}
prompt += `
CAPACIDADES:
- Generar exámenes simulados adaptados al progreso
- Crear ejercicios graduados por dificultad
- Dar explicaciones paso a paso
- Señalar errores recurrentes y sugerir correcciones
- Mantener roadmap de estudio personalizado
FORMATO DE RESPUESTA:
- Para fórmulas matemáticas, usar SIEMPRE LaTeX inline con $...$ y bloques con $$...$$. Ejemplo: $d = \\sqrt{(x_2-x_1)^2 + (y_2-y_1)^2}$
- Para gráficos de coordenadas, planos, rectas, o puntos, usar un bloque de código \`\`\`graph seguido de un JSON con el formato: {"points":[[x1,y1],[x2,y2]], "segments":[[x1,y1,x2,y2]], "grid":[xMin,xMax,yMin,yMax]}. Ejemplo para graficar A(1,2) y B(4,6):
\`\`\`graph
{"points":[[1,2],[4,6]], "segments":[[1,2,4,6]], "grid":[-1,6,-1,7]}
\`\`\`
- Para código o comandos, usar bloques de código con el lenguaje correspondiente
FORMATO DE EJERCICIOS: Cuando el usuario resuelva un ejercicio, al final de tu respuesta incluí exactamente este JSON (invisible para el usuario, solo para tracking):
{"exercise_logged": {"topic": "nombre_del_topic", "correct": true/false}}
`;
return prompt;
}
function buildForkPrompt(conversation) {
return `Sos un tutor especializado EXCLUSIVAMENTE en el tema: ${conversation.title}.
Este es un micro-chat derivado de la sesión principal de estudio.
REGLA ESTRICTA: No salgas del tema asignado. Si el usuario pregunta algo fuera de scope, redirigilo amablemente al tema.
Cuando el usuario termine, el contexto de esta sesión se va a integrar automáticamente al chat principal.
Podés usar ejercicios, ejemplos, preguntas y explicaciones paso a paso sobre ${conversation.title}.`;
}
function formatProgressRows(rows) {
if (!rows || rows.length === 0) {
return '(Sin datos de progreso aún. Empezá practicando cualquier tema.)';
}
return rows.map(r => {
const pct = r.exercises_done > 0 ? Math.round((r.exercises_correct / r.exercises_done) * 100) : 0;
let status;
if (pct >= 80) status = '✓ DOMINADO';
else if (pct >= 50) status = '→ en progreso';
else status = '✗ necesita práctica';
return `- ${r.topic}: ${r.exercises_done} ejercicios, ${pct}% correctos ${status}`;
}).join('\n');
}
function formatPDFList(pdfs) {
return pdfs
.sort((a, b) => a.reorder_index - b.reorder_index)
.map((p, i) => `${i + 1}. ${p.original_name}${p.content_markdown ? ' [contenido extraído]' : ' [pendiente de procesar]'}`)
.join('\n');
}
module.exports = { buildSystemPrompt };