198 lines
10 KiB
JavaScript
198 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Minimaler Brain-Server (#88).
|
||
* POST /chat { message, buddy, memories } → { reply }
|
||
* Optional: OPENAI_API_KEY / OLLAMA_URL – sonst Stub-Antwort aus Memories.
|
||
*/
|
||
const http = require('http');
|
||
const PORT = Number(process.env.PORT || 8787);
|
||
const sync = require('./sync');
|
||
const kv = require('./db').createStore();
|
||
let features = null;
|
||
const auth = require('./auth').createAuth(kv, { onRegister: (u) => features && features.sendVerify(u) });
|
||
const mailer = require('./mailer').createMailer();
|
||
features = require('./features').createFeatures(kv, auth, mailer);
|
||
console.log('[code-kumpel-brain] mail:', mailer.kind || 'aus', '| oauth:', features.config().body.oauth.join(',') || 'aus');
|
||
console.log('[code-kumpel-brain] storage:', kv.kind);
|
||
const ALLOWED_ORIGINS = new Set(['https://code-kumpel.vercel.app', 'https://code-kumpel.onrender.com', 'https://code-kumpel.jodabreit-ai.de', 'https://localhost', 'http://localhost', 'capacitor://localhost', 'http://localhost:8080', 'http://127.0.0.1:8080', 'http://localhost:3000', 'http://localhost:5173']
|
||
.concat((process.env.CORS_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean)));
|
||
const RATE = new Map(); // ip -> timestamps
|
||
const MAX_PER_MIN = 30;
|
||
|
||
function rateOk(ip) {
|
||
const now = Date.now();
|
||
const arr = (RATE.get(ip) || []).filter((t) => now - t < 60_000);
|
||
if (arr.length >= MAX_PER_MIN) {
|
||
RATE.set(ip, arr);
|
||
return false;
|
||
}
|
||
arr.push(now);
|
||
RATE.set(ip, arr);
|
||
return true;
|
||
}
|
||
|
||
function cors(req, res) {
|
||
const o = req.headers.origin;
|
||
// /chat + /health bleiben offen (Extension/VS Code); Konto-/Sync-Endpunkte nur für bekannte Origins
|
||
const isAuthPath = /^\/(auth|sync|backups|leaderboard)(\/|\?|$)/.test(req.url || '');
|
||
if (!isAuthPath) res.setHeader('Access-Control-Allow-Origin', '*');
|
||
else if (o && (ALLOWED_ORIGINS.has(o) || /^chrome-extension:\/\//.test(o) || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o))) { res.setHeader('Access-Control-Allow-Origin', o); res.setHeader('Vary', 'Origin'); }
|
||
res.setHeader('Access-Control-Allow-Methods', 'POST, PUT, DELETE, OPTIONS, GET');
|
||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Sync-Code, Authorization');
|
||
res.setHeader('Access-Control-Max-Age', '600');
|
||
}
|
||
const send = (res, status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
|
||
function clientIp(req) { const f = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim(); return f || req.socket.remoteAddress || 'local'; }
|
||
|
||
function readBody(req) {
|
||
return new Promise((resolve, reject) => {
|
||
const chunks = [];
|
||
req.on('data', (c) => chunks.push(c));
|
||
req.on('end', () => {
|
||
try {
|
||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
});
|
||
req.on('error', reject);
|
||
});
|
||
}
|
||
|
||
function stubReply(body) {
|
||
const name = (body.buddy && body.buddy.name) || 'Byte';
|
||
const mems = body.memories || [];
|
||
if (!mems.length) {
|
||
return `${name} (Brain-Stub): Dazu hab ich lokal nichts. Bring mir was bei oder setze OPENAI_API_KEY / OLLAMA_URL.`;
|
||
}
|
||
const lines = mems.slice(0, 3).map((m) => `• ${m.title}: ${(m.body || '').slice(0, 120)}`);
|
||
return `${name} erinnert sich:\n${lines.join('\n')}\n\n(Stub – kein LLM verbunden.)`;
|
||
}
|
||
|
||
async function llmReply(body) {
|
||
const ollama = process.env.OLLAMA_URL; // e.g. http://127.0.0.1:11434/api/generate
|
||
const key = process.env.OPENAI_API_KEY;
|
||
const sys = `Du bist ${body.buddy?.name || 'Byte'}, Level ${body.buddy?.level || 1}, Stufe ${body.buddy?.stage || ''}. Antworte kurz auf Deutsch. Nutze diese Erinnerungen:\n` +
|
||
(body.memories || []).map((m) => `- ${m.title}: ${m.body}`).join('\n');
|
||
|
||
if (ollama) {
|
||
const r = await fetch(ollama, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model: process.env.OLLAMA_MODEL || 'llama3.2',
|
||
prompt: sys + '\n\nUser: ' + body.message + '\nAssistant:',
|
||
stream: false
|
||
})
|
||
});
|
||
const d = await r.json();
|
||
return d.response || d.text || stubReply(body);
|
||
}
|
||
if (key) {
|
||
const r = await fetch('https://api.openai.com/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + key },
|
||
body: JSON.stringify({
|
||
model: process.env.OPENAI_MODEL || 'gpt-4o-mini',
|
||
messages: [
|
||
{ role: 'system', content: sys },
|
||
{ role: 'user', content: body.message }
|
||
]
|
||
})
|
||
});
|
||
const d = await r.json();
|
||
return (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content) || stubReply(body);
|
||
}
|
||
return stubReply(body);
|
||
}
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
cors(req, res);
|
||
if (req.method === 'OPTIONS') {
|
||
res.writeHead(204);
|
||
return res.end();
|
||
}
|
||
if (req.method === 'GET' && (req.url === '/' || req.url === '/health')) {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ ok: true, service: 'code-kumpel-brain', port: PORT }));
|
||
}
|
||
if (req.method === 'POST' && (req.url === '/chat' || req.url.startsWith('/chat?'))) {
|
||
const ip = req.socket.remoteAddress || 'local';
|
||
if (!rateOk(ip)) {
|
||
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ error: 'rate_limit' }));
|
||
}
|
||
try {
|
||
const body = await readBody(req);
|
||
const reply = await llmReply(body);
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ reply }));
|
||
} catch (e) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ error: String(e.message || e) }));
|
||
}
|
||
}
|
||
const pathOnly = (req.url || '').split('?')[0];
|
||
if (pathOnly.startsWith('/auth/')) {
|
||
const ip = clientIp(req), h = req.headers.authorization;
|
||
let body = {};
|
||
try { if (req.method !== 'GET') body = await readBody(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
|
||
try {
|
||
let out;
|
||
if (pathOnly === '/auth/register' && req.method === 'POST') out = await auth.register(body, ip);
|
||
else if (pathOnly === '/auth/login' && req.method === 'POST') out = await auth.login(body, ip);
|
||
else if (pathOnly === '/auth/logout' && req.method === 'POST') out = await auth.logout(h);
|
||
else if (pathOnly === '/auth/me' && req.method === 'GET') out = await auth.me(h);
|
||
else if (pathOnly === '/auth/password' && (req.method === 'POST' || req.method === 'PUT')) out = await auth.changePassword(h, body, ip);
|
||
else if (pathOnly === '/auth/config' && req.method === 'GET') out = features.config();
|
||
else if (pathOnly === '/auth/forgot' && req.method === 'POST') out = await features.forgot(body, ip);
|
||
else if (pathOnly === '/auth/reset' && req.method === 'POST') out = await features.reset(body, ip);
|
||
else if (pathOnly === '/auth/verify' && req.method === 'POST') out = await features.verify(body);
|
||
else if (pathOnly === '/auth/resend-verify' && req.method === 'POST') out = await features.resendVerify(h, ip);
|
||
else if (pathOnly === '/auth/oauth/exchange' && req.method === 'POST') out = await features.oauthExchange(body);
|
||
else if (/^\/auth\/oauth\/(github|google)\/start$/.test(pathOnly) && req.method === 'GET') out = await features.oauthStart(pathOnly.split('/')[3], new URL(req.url, 'http://x').searchParams);
|
||
else if (/^\/auth\/oauth\/(github|google)\/callback$/.test(pathOnly) && req.method === 'GET') out = await features.oauthCallback(pathOnly.split('/')[3], new URL(req.url, 'http://x').searchParams);
|
||
else if ((pathOnly === '/auth/account' && req.method === 'DELETE') || (pathOnly === '/auth/delete' && req.method === 'POST')) { const a0 = await auth.authenticate(h); out = await auth.deleteAccount(h, body, ip); if (a0 && out.status === 200) await features.onDelete(a0.user.id); }
|
||
else out = { status: 404, body: { error: 'not_found' } };
|
||
if (out.location) { res.writeHead(302, { Location: out.location, 'Cache-Control': 'no-store' }); return res.end(); }
|
||
return send(res, out.status, out.body);
|
||
} catch (e) { console.error('[auth]', e && e.message); return send(res, 500, { error: 'server_error' }); }
|
||
}
|
||
if (pathOnly === '/sync') {
|
||
const ip = clientIp(req);
|
||
if (!rateOk(ip)) return send(res, 429, { error: 'rate_limit' });
|
||
let body = null;
|
||
try { if (req.method !== 'GET') body = await readBody(req); } catch (e) { body = null; }
|
||
try {
|
||
let id = null;
|
||
if (req.headers.authorization) { const a = await auth.authenticate(req.headers.authorization); if (!a) return send(res, 401, { error: 'unauthorized' }); id = 'user:' + a.user.id; }
|
||
else id = sync.idFor(req.headers['x-sync-code']);
|
||
const prev = id && id.startsWith('user:') && req.method !== 'GET' ? await kv.get('sync', id) : null;
|
||
const out = await sync.handleAsync(kv, req.method, id, body);
|
||
if (prev && out.status === 200) await features.snapshot(id.slice(5), prev);
|
||
return send(res, out.status, out.body);
|
||
} catch (e) { console.error('[sync]', e && e.message); return send(res, 500, { error: 'server_error' }); }
|
||
}
|
||
if (pathOnly === '/backups' || pathOnly === '/backups/restore' || pathOnly === '/leaderboard' || pathOnly === '/errors') {
|
||
const ip = clientIp(req), h = req.headers.authorization;
|
||
let body = {};
|
||
try { if (req.method !== 'GET') body = await readBody(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
|
||
try {
|
||
let out;
|
||
if (pathOnly === '/backups' && req.method === 'GET') out = await features.backupsList(h);
|
||
else if (pathOnly === '/backups/restore' && req.method === 'POST') out = await features.backupsRestore(h, body);
|
||
else if (pathOnly === '/leaderboard' && req.method === 'GET') out = await features.lbGet();
|
||
else if (pathOnly === '/leaderboard' && req.method === 'POST') out = await features.lbPost(h, body);
|
||
else if (pathOnly === '/errors' && req.method === 'POST') out = await features.reportError(body, ip);
|
||
else if (pathOnly === '/errors' && req.method === 'GET') out = await features.listErrors(h);
|
||
else out = { status: 405, body: { error: 'method' } };
|
||
return send(res, out.status, out.body);
|
||
} catch (e) { console.error('[api]', e && e.message); return send(res, 500, { error: 'server_error' }); }
|
||
}
|
||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'not_found' }));
|
||
});
|
||
|
||
server.listen(PORT, () => {
|
||
console.log(`[code-kumpel-brain] http://localhost:${PORT}/chat`);
|
||
});
|