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)
309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
const express = require('express');
|
|
const crypto = require('crypto');
|
|
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
|
|
// 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));
|
|
|
|
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' });
|
|
}
|
|
|
|
// 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 }], undefined)) {
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// 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;
|