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;