Initial commit: StudyOS platform
This commit is contained in:
53
.atl/skill-registry.md
Normal file
53
.atl/skill-registry.md
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/*.db
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
76
README.md
Normal file
76
README.md
Normal 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
16
cdp-test.js
Normal 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
17
client/index.html
Normal 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
3438
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
client/package.json
Normal file
28
client/package.json
Normal 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
248
client/src/App.css
Normal 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
186
client/src/App.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
client/src/components/ChatInput.jsx
Normal file
238
client/src/components/ChatInput.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
client/src/components/ForkPanel.jsx
Normal file
213
client/src/components/ForkPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
client/src/components/MainChat.jsx
Normal file
163
client/src/components/MainChat.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
client/src/components/MessageBubble.jsx
Normal file
146
client/src/components/MessageBubble.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
client/src/components/ModelSelector.jsx
Normal file
129
client/src/components/ModelSelector.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
client/src/components/Sidebar.jsx
Normal file
177
client/src/components/Sidebar.jsx
Normal 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
187
client/src/hooks/useChat.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
74
client/src/hooks/usePdfs.js
Normal file
74
client/src/hooks/usePdfs.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
74
client/src/hooks/useProgress.js
Normal file
74
client/src/hooks/useProgress.js
Normal 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
68
client/src/index.css
Normal 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
312
client/src/lib/api.js
Normal 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
12
client/src/main.jsx
Normal 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>
|
||||||
|
);
|
||||||
745
client/src/pages/Settings.jsx
Normal file
745
client/src/pages/Settings.jsx
Normal 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
21
client/vite.config.js
Normal 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');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
data/uploads/1780941972739-624056697.pdf
Normal file
BIN
data/uploads/1780941972739-624056697.pdf
Normal file
Binary file not shown.
BIN
data/uploads/1780942181559-786040171.pdf
Normal file
BIN
data/uploads/1780942181559-786040171.pdf
Normal file
Binary file not shown.
23
screen.js
Normal file
23
screen.js
Normal 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
181
server/db.js
Normal 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
71
server/index.js
Normal 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
91
server/lib/llm.js
Normal 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
1645
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
server/package.json
Normal file
21
server/package.json
Normal 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
213
server/routes/chat.js
Normal 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
66
server/routes/config.js
Normal 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;
|
||||||
201
server/routes/conversations.js
Normal file
201
server/routes/conversations.js
Normal 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
163
server/routes/models.js
Normal 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
104
server/routes/notes.js
Normal 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
211
server/routes/pdfs.js
Normal 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
78
server/routes/progress.js
Normal 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;
|
||||||
120
server/systemPromptBuilder.js
Normal file
120
server/systemPromptBuilder.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user