Initial commit: StudyOS platform
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user