Initial commit: StudyOS platform

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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