feat: implement 33 nice-to-have features + fix 37 code review bugs

5 SDD batches archived:
- Batch 1: UI Polish (10 features, 14 tasks)
- Batch 2: Study System (8 features, 23 tasks)
- Batch 3: Infrastructure (5 features, 22 tasks)
- Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers
- Batch 5: Core Features (5 features, 19 tasks)

37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW):
- SSE streaming now works (event.token check)
- API keys no longer exposed via GET /api/models
- FTS5 injection sanitized
- DB backup/restore with admin auth
- Buddy mode wired (buddy_meta column)
- Exam auto-submit stale closure fixed
- CSS variables aligned with design tokens
- Progress data corruption fixed
- WebSocket protocol auto-detection
- Tests infrastructure completed (vitest + node:test)
This commit is contained in:
renato97
2026-06-08 18:18:47 -03:00
parent b7d1e7319f
commit 4ff4302a8c
79 changed files with 13667 additions and 389 deletions

View File

@@ -1,4 +1,5 @@
const express = require('express');
const crypto = require('crypto');
const db = require('../db');
const { buildSystemPrompt } = require('../systemPromptBuilder');
const { streamCompletion } = require('../lib/llm');
@@ -114,6 +115,7 @@ router.post('/:id/fork', (req, res) => {
const newId = info.lastInsertRowid;
// Persist fork_point in config table
// TODO: migrate fork_point to a dedicated column on conversations table instead of config
db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
.run(`fork_point_${newId}`, String(forkPoint));
@@ -169,11 +171,13 @@ router.post('/:id/merge', async (req, res) => {
return res.status(500).json({ error: 'No model available for summarization' });
}
const transcript = forkMessages.map(m => `${m.role}: ${m.content}`).join('\n\n');
// Truncate to last 50 messages to avoid exceeding context window
const recentMessages = forkMessages.slice(-50);
const transcript = recentMessages.map(m => `${m.role}: ${m.content}`).join('\n\n');
const summarizePrompt = `Resume en 2-3 oraciones qué aprendió el usuario en esta sesión sobre ${fork.title}:\n\n${transcript}`;
let summary = '';
for await (const chunk of streamCompletion(model, [{ role: 'user', content: summarizePrompt }], '')) {
for await (const chunk of streamCompletion(model, [{ role: 'user', content: summarizePrompt }], undefined)) {
if (chunk.error) {
return res.status(502).json({ error: chunk.error });
}
@@ -198,4 +202,107 @@ router.post('/:id/merge', async (req, res) => {
}
});
// POST /api/conversations/:id/share — generate a share token for buddy mode
router.post('/:id/share', (req, res) => {
const convId = parseInt(req.params.id, 10);
if (Number.isNaN(convId)) {
return res.status(400).json({ error: 'Invalid conversation id' });
}
const { role_label = 'compañero' } = req.body;
try {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(convId);
if (!conv) {
return res.status(404).json({ error: 'Conversation not found' });
}
const token = crypto.randomBytes(16).toString('hex');
db.prepare('INSERT INTO shared_conversations (token, conv_id, role_label) VALUES (?, ?, ?)')
.run(token, convId, role_label);
const shareUrl = `${req.protocol}://${req.get('host')}/shared/${token}`;
res.json({ token, share_url: shareUrl, role_label });
} catch (err) {
console.error('[conversations] share error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/conversations/shared/:token — get shared conversation data
router.get('/shared/:token', (req, res) => {
const { token } = req.params;
if (!token) {
return res.status(400).json({ error: 'token is required' });
}
try {
const shared = db.prepare('SELECT * FROM shared_conversations WHERE token = ?').get(token);
if (!shared) {
return res.status(404).json({ error: 'Shared conversation not found' });
}
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(shared.conv_id);
if (!conv) {
return res.status(404).json({ error: 'Conversation not found' });
}
const messages = db.prepare(
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at, id'
).all(shared.conv_id);
// Find counterpart label (if there are 2 shared entries for same conv)
const allShared = db.prepare('SELECT * FROM shared_conversations WHERE conv_id = ?').all(shared.conv_id);
const counterpart = allShared.find((s) => s.token !== token);
res.json({
conversation: conv,
messages,
role_label: shared.role_label,
counterpart_label: counterpart?.role_label || 'compañero',
});
} catch (err) {
console.error('[conversations] shared get error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/conversations/join — join a shared conversation by token
router.post('/join', (req, res) => {
const { share_token, user_name } = req.body;
if (!share_token || !user_name) {
return res.status(400).json({ error: 'share_token and user_name are required' });
}
try {
const shared = db.prepare('SELECT * FROM shared_conversations WHERE token = ?').get(share_token);
if (!shared) {
return res.status(404).json({ error: 'Shared conversation not found' });
}
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(shared.conv_id);
if (!conv) {
return res.status(404).json({ error: 'Conversation not found' });
}
// Track participant
db.prepare('INSERT INTO conversation_participants (conversation_id, user_name) VALUES (?, ?)')
.run(shared.conv_id, user_name);
const messages = db.prepare(
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at, id'
).all(shared.conv_id);
res.json({
conversation: conv,
messages,
role_label: shared.role_label,
share_token,
});
} catch (err) {
console.error('[conversations] join error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;