Initial commit: StudyOS platform
This commit is contained in:
181
server/db.js
Normal file
181
server/db.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const DATA_DIR = path.resolve(__dirname, '..', 'data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'studyos.db');
|
||||
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
let _sqlDb = null;
|
||||
|
||||
function saveToDisk() {
|
||||
if (!_sqlDb) return;
|
||||
const data = _sqlDb.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
}
|
||||
|
||||
function flatParams(params) {
|
||||
if (params.length === 1 && Array.isArray(params[0])) return params[0];
|
||||
return params;
|
||||
}
|
||||
|
||||
function createWrapper(sqlDb) {
|
||||
return {
|
||||
prepare(sql) {
|
||||
return {
|
||||
run(...params) {
|
||||
const p = flatParams(params);
|
||||
sqlDb.run(sql, p);
|
||||
const rowidResult = sqlDb.exec('SELECT last_insert_rowid()');
|
||||
const lastInsertRowid = rowidResult.length > 0 ? rowidResult[0].values[0][0] : 0;
|
||||
const changes = sqlDb.getRowsModified();
|
||||
saveToDisk();
|
||||
return { changes, lastInsertRowid };
|
||||
},
|
||||
get(...params) {
|
||||
const p = flatParams(params);
|
||||
let stmt = null;
|
||||
try {
|
||||
stmt = sqlDb.prepare(sql);
|
||||
stmt.bind(p);
|
||||
let result = null;
|
||||
if (stmt.step()) result = stmt.getAsObject();
|
||||
return result;
|
||||
} finally {
|
||||
if (stmt) stmt.free();
|
||||
}
|
||||
},
|
||||
all(...params) {
|
||||
const p = flatParams(params);
|
||||
let stmt = null;
|
||||
try {
|
||||
stmt = sqlDb.prepare(sql);
|
||||
stmt.bind(p);
|
||||
const results = [];
|
||||
while (stmt.step()) results.push(stmt.getAsObject());
|
||||
return results;
|
||||
} finally {
|
||||
if (stmt) stmt.free();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
exec(sql) {
|
||||
sqlDb.exec(sql);
|
||||
saveToDisk();
|
||||
},
|
||||
pragma(sql) {
|
||||
sqlDb.exec('PRAGMA ' + sql + ';');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const db = createWrapper(null);
|
||||
|
||||
async function initDB() {
|
||||
const SQL = await import('sql.js');
|
||||
const initSqlJs = SQL.default;
|
||||
const sqlModule = await initSqlJs();
|
||||
|
||||
let sqlDb;
|
||||
if (fs.existsSync(DB_PATH)) {
|
||||
const buffer = fs.readFileSync(DB_PATH);
|
||||
sqlDb = new sqlModule.Database(buffer);
|
||||
} else {
|
||||
sqlDb = new sqlModule.Database();
|
||||
}
|
||||
|
||||
_sqlDb = sqlDb;
|
||||
|
||||
const real = createWrapper(sqlDb);
|
||||
db.prepare = real.prepare;
|
||||
db.exec = real.exec;
|
||||
db.pragma = real.pragma;
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
api_base TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
provider TEXT NOT NULL CHECK(provider IN ('openai', 'anthropic')),
|
||||
is_default_main INTEGER NOT NULL DEFAULT 0,
|
||||
is_default_fork INTEGER NOT NULL DEFAULT 0,
|
||||
is_default_exam INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('main', 'fork')),
|
||||
parent_id INTEGER REFERENCES conversations(id) ON DELETE SET NULL,
|
||||
model_id INTEGER REFERENCES models(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'context_merge')),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pdfs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
content_markdown TEXT NOT NULL DEFAULT '',
|
||||
pages INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reorder_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS progress (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
topic TEXT NOT NULL UNIQUE,
|
||||
exercises_done INTEGER NOT NULL DEFAULT 0,
|
||||
exercises_correct INTEGER NOT NULL DEFAULT 0,
|
||||
last_session TEXT,
|
||||
notes TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content_markdown TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`);
|
||||
|
||||
const modelCount = db.prepare('SELECT COUNT(*) as count FROM models').get();
|
||||
if (!modelCount || modelCount.count === 0) {
|
||||
db.prepare(`
|
||||
INSERT INTO models (name, api_base, api_key, provider, is_default_main)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('claude-sonnet-4', 'https://api.anthropic.com', '', 'anthropic', 1);
|
||||
}
|
||||
|
||||
const vlmConfig = db.prepare("SELECT value FROM config WHERE key = ?").get('vlm_endpoint');
|
||||
if (!vlmConfig) {
|
||||
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('vlm_endpoint', 'http://localhost:8080/vlm');
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
module.exports = db;
|
||||
module.exports.initDB = initDB;
|
||||
71
server/index.js
Normal file
71
server/index.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
|
||||
const db = require('./db');
|
||||
const pdfRoutes = require('./routes/pdfs');
|
||||
const conversationRoutes = require('./routes/conversations');
|
||||
const chatRoutes = require('./routes/chat');
|
||||
const progressRoutes = require('./routes/progress');
|
||||
const notesRoutes = require('./routes/notes');
|
||||
const modelRoutes = require('./routes/models');
|
||||
const configRoutes = require('./routes/config');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: ['http://localhost:5173', 'http://localhost:3001'],
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// API Routes
|
||||
app.use('/api/pdfs', pdfRoutes);
|
||||
app.use('/api/conversations', conversationRoutes);
|
||||
app.use('/api/chat', chatRoutes);
|
||||
app.use('/api/progress', progressRoutes);
|
||||
app.use('/api/notes', notesRoutes);
|
||||
app.use('/api/models', modelRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
|
||||
// Serve React build in production
|
||||
const clientDist = path.resolve(__dirname, '..', 'client', 'dist');
|
||||
app.use(express.static(clientDist));
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api')) return next();
|
||||
res.sendFile(path.join(clientDist, 'index.html'), (err) => {
|
||||
if (err) next();
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket server
|
||||
const wss = new WebSocketServer({ server });
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (message) => {
|
||||
// Echo — extensible for real-time notifications
|
||||
});
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'connected', message: 'StudyOS WebSocket connected' }));
|
||||
} catch (err) {
|
||||
console.error('[ws] send error:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function start() {
|
||||
await db.initDB();
|
||||
server.listen(PORT, () => {
|
||||
console.log(`StudyOS server running on http://localhost:${PORT}`);
|
||||
console.log(`WebSocket server running on ws://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch(err => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
91
server/lib/llm.js
Normal file
91
server/lib/llm.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
const OpenAI = require('openai');
|
||||
|
||||
/**
|
||||
* Normalize Anthropic + OpenAI-compatible streams into one AsyncIterable.
|
||||
* Yields: { token, done, fullText } events.
|
||||
* Handles errors gracefully — emits error event, doesn't crash.
|
||||
*
|
||||
* Usage:
|
||||
* for await (const chunk of streamCompletion(model, messages, systemPrompt)) {
|
||||
* // chunk = { token: string, done: boolean, fullText: string }
|
||||
* }
|
||||
*/
|
||||
async function* streamCompletion(model, messages, systemPrompt) {
|
||||
if (!model || !model.provider) {
|
||||
yield { token: '', done: true, fullText: '', error: 'Model or provider not specified' };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (model.provider === 'anthropic') {
|
||||
yield* streamAnthropic(model, messages, systemPrompt);
|
||||
} else if (model.provider === 'openai') {
|
||||
yield* streamOpenAI(model, messages, systemPrompt);
|
||||
} else {
|
||||
yield { token: '', done: true, fullText: '', error: `Unknown provider: ${model.provider}` };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[llm] streamCompletion error:', err.message);
|
||||
yield { token: '', done: true, fullText: '', error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function* streamAnthropic(model, messages, systemPrompt) {
|
||||
const client = new Anthropic({
|
||||
apiKey: model.api_key || process.env.ANTHROPIC_API_KEY,
|
||||
});
|
||||
|
||||
const stream = await client.messages.create({
|
||||
model: model.name,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt || undefined,
|
||||
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
||||
const token = event.delta.text || '';
|
||||
fullText += token;
|
||||
yield { token, done: false, fullText };
|
||||
}
|
||||
}
|
||||
|
||||
yield { token: '', done: true, fullText };
|
||||
}
|
||||
|
||||
async function* streamOpenAI(model, messages, systemPrompt) {
|
||||
const client = new OpenAI({
|
||||
apiKey: model.api_key || process.env.OPENAI_API_KEY,
|
||||
baseURL: model.api_base,
|
||||
});
|
||||
|
||||
const openaiMessages = [];
|
||||
if (systemPrompt) {
|
||||
openaiMessages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
openaiMessages.push(...messages.map(m => ({ role: m.role, content: m.content })));
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.name,
|
||||
messages: openaiMessages,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const token = chunk.choices?.[0]?.delta?.content || '';
|
||||
if (token) {
|
||||
fullText += token;
|
||||
yield { token, done: false, fullText };
|
||||
}
|
||||
}
|
||||
|
||||
yield { token: '', done: true, fullText };
|
||||
}
|
||||
|
||||
module.exports = { streamCompletion };
|
||||
1645
server/package-lock.json
generated
Normal file
1645
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
server/package.json
Normal file
21
server/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "studyos-server",
|
||||
"version": "1.0.0",
|
||||
"description": "StudyOS backend server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^4.70.0",
|
||||
"pdfjs-dist": "^4.4.168",
|
||||
"playwright": "^1.60.0",
|
||||
"sql.js": "^1.10.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
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;
|
||||
120
server/systemPromptBuilder.js
Normal file
120
server/systemPromptBuilder.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Builds the system prompt for a conversation based on its type, user progress,
|
||||
* available PDFs, and any attachment texts.
|
||||
*/
|
||||
function buildSystemPrompt(conversation, progressRows = [], pdfContents = [], attachmentTexts = []) {
|
||||
if (conversation.type === 'main') {
|
||||
return buildMainPrompt(progressRows, pdfContents, attachmentTexts);
|
||||
}
|
||||
if (conversation.type === 'fork') {
|
||||
return buildForkPrompt(conversation);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildMainPrompt(progressRows, pdfContents, attachmentTexts) {
|
||||
let prompt = `Sos un tutor de estudio personal especializado. Tu objetivo es ayudar al usuario a aprender de forma eficiente y con seguimiento real de su progreso.
|
||||
|
||||
PROGRESO ACTUAL DEL USUARIO:
|
||||
${formatProgressRows(progressRows)}
|
||||
|
||||
REGLAS PARA PARCIALES SIMULADOS:
|
||||
- Temas marcados como DOMINADO (>=80%): incluir máximo 1-2 ejercicios simples de repaso.
|
||||
- Temas en progreso o sin práctica: incluir proporcionalmente más ejercicios.
|
||||
`;
|
||||
|
||||
if (pdfContents.length > 0) {
|
||||
prompt += `
|
||||
PDFS DISPONIBLES (en orden de prioridad del usuario):
|
||||
${formatPDFList(pdfContents)}
|
||||
Cuando el usuario pida contenido de un PDF, incluir el markdown relevante en tu respuesta.
|
||||
`;
|
||||
|
||||
const MAX_CONTENT_LENGTH = 30000;
|
||||
let pdfContentBlocks = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (const pdf of pdfContents) {
|
||||
if (!pdf.content_markdown) continue;
|
||||
const content = pdf.content_markdown;
|
||||
totalLength += content.length;
|
||||
pdfContentBlocks.push({ name: pdf.original_name, content });
|
||||
}
|
||||
|
||||
if (totalLength > MAX_CONTENT_LENGTH) {
|
||||
const ratio = MAX_CONTENT_LENGTH / totalLength;
|
||||
pdfContentBlocks = pdfContentBlocks.map((b) => ({
|
||||
name: b.name,
|
||||
content:
|
||||
b.content.substring(0, Math.floor(b.content.length * ratio)) +
|
||||
'\n\n[Contenido truncado por límites de contexto]',
|
||||
}));
|
||||
}
|
||||
|
||||
if (pdfContentBlocks.length > 0) {
|
||||
prompt += pdfContentBlocks
|
||||
.map((b) => `\n--- CONTENIDO DE "${b.name}" ---\n${b.content}`)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentTexts.length > 0) {
|
||||
prompt += `
|
||||
ARCHIVOS ADJUNTOS EN ESTA CONSULTA:
|
||||
${attachmentTexts.map((t, i) => `--- Adjunto ${i + 1} ---\n${t}`).join('\n\n')}
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
CAPACIDADES:
|
||||
- Generar exámenes simulados adaptados al progreso
|
||||
- Crear ejercicios graduados por dificultad
|
||||
- Dar explicaciones paso a paso
|
||||
- Señalar errores recurrentes y sugerir correcciones
|
||||
- Mantener roadmap de estudio personalizado
|
||||
|
||||
FORMATO DE RESPUESTA:
|
||||
- Para fórmulas matemáticas, usar SIEMPRE LaTeX inline con $...$ y bloques con $$...$$. Ejemplo: $d = \\sqrt{(x_2-x_1)^2 + (y_2-y_1)^2}$
|
||||
- Para gráficos de coordenadas, planos, rectas, o puntos, usar un bloque de código \`\`\`graph seguido de un JSON con el formato: {"points":[[x1,y1],[x2,y2]], "segments":[[x1,y1,x2,y2]], "grid":[xMin,xMax,yMin,yMax]}. Ejemplo para graficar A(1,2) y B(4,6):
|
||||
\`\`\`graph
|
||||
{"points":[[1,2],[4,6]], "segments":[[1,2,4,6]], "grid":[-1,6,-1,7]}
|
||||
\`\`\`
|
||||
- Para código o comandos, usar bloques de código con el lenguaje correspondiente
|
||||
|
||||
FORMATO DE EJERCICIOS: Cuando el usuario resuelva un ejercicio, al final de tu respuesta incluí exactamente este JSON (invisible para el usuario, solo para tracking):
|
||||
{"exercise_logged": {"topic": "nombre_del_topic", "correct": true/false}}
|
||||
`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function buildForkPrompt(conversation) {
|
||||
return `Sos un tutor especializado EXCLUSIVAMENTE en el tema: ${conversation.title}.
|
||||
Este es un micro-chat derivado de la sesión principal de estudio.
|
||||
REGLA ESTRICTA: No salgas del tema asignado. Si el usuario pregunta algo fuera de scope, redirigilo amablemente al tema.
|
||||
Cuando el usuario termine, el contexto de esta sesión se va a integrar automáticamente al chat principal.
|
||||
Podés usar ejercicios, ejemplos, preguntas y explicaciones paso a paso sobre ${conversation.title}.`;
|
||||
}
|
||||
|
||||
function formatProgressRows(rows) {
|
||||
if (!rows || rows.length === 0) {
|
||||
return '(Sin datos de progreso aún. Empezá practicando cualquier tema.)';
|
||||
}
|
||||
return rows.map(r => {
|
||||
const pct = r.exercises_done > 0 ? Math.round((r.exercises_correct / r.exercises_done) * 100) : 0;
|
||||
let status;
|
||||
if (pct >= 80) status = '✓ DOMINADO';
|
||||
else if (pct >= 50) status = '→ en progreso';
|
||||
else status = '✗ necesita práctica';
|
||||
return `- ${r.topic}: ${r.exercises_done} ejercicios, ${pct}% correctos ${status}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function formatPDFList(pdfs) {
|
||||
return pdfs
|
||||
.sort((a, b) => a.reorder_index - b.reorder_index)
|
||||
.map((p, i) => `${i + 1}. ${p.original_name}${p.content_markdown ? ' [contenido extraído]' : ' [pendiente de procesar]'}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { buildSystemPrompt };
|
||||
Reference in New Issue
Block a user