Files
researchChat/server.js
T
nico 87eb7029f1 Multi-Modell + Vision: OpenRouter (GLM 5.3 Flash), Bild-Upload, Tour-Feature, Dashboard-Fixes
- Modell-Registry (DeepSeek direkt + OpenRouter z-ai/glm-5.3-flash), Modellwahl pro Session, Reasoning-Effort max (env-konfigurierbar)
- Bild-Upload für Vision-Modelle (User) + Agent-screenshot()-Tool (nur Vision)
- Tour-Planung: geocode + save_tour mit GPX-Export und Karten-Sektion
- Fix: System-Prompt wird jetzt wirklich an die API gesendet, inkl. aktuellem Dashboard-Dateinamen für Folge-Updates
- Fix: History-Sanitizer gegen 400-Fehler (nie mitten in Tool-Gruppen kürzen)
- Fix: reasoning_content-Roundtrip (Pflicht bei DeepSeek mit tools)
- Fix: Dashboards ohne Browser-Cache ausliefern
2026-08-27 17:43:13 +02:00

437 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const express = require('express');
const webpush = require('web-push');
const { config, getModel, getModels } = require('./lib/config');
const db = require('./lib/db');
const queue = require('./lib/queue');
const { runResearchJob } = require('./lib/research');
const { closeBrowser } = require('./lib/tools');
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
const server = http.createServer(app);
// ---------------------------------------------------------------------------
// Auth (eine Anmeldung: Name + gemeinsames Passwort, signiertes Cookie)
// ---------------------------------------------------------------------------
function signToken(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url');
return `${body}.${sig}`;
}
function verifyToken(token) {
try {
const [body, sig] = token.split('.');
const expected = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url');
if (sig !== expected) return null;
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
function cookieOptions(req) {
return {
httpOnly: true,
sameSite: 'lax',
secure: req.secure,
maxAge: 30 * 24 * 3600 * 1000,
path: '/',
};
}
function requireAuth(req, res, next) {
const payload = verifyToken(req.cookies?.research_auth);
if (!payload) return res.status(401).json({ error: 'Nicht angemeldet' });
req.user = payload;
next();
}
app.use((req, res, next) => {
req.cookies = Object.fromEntries(
(req.headers.cookie || '')
.split(';')
.map((c) => c.trim().split('='))
.filter(([k]) => k)
.map(([k, ...v]) => [k, decodeURIComponent(v.join('='))])
);
next();
});
// ---------------------------------------------------------------------------
// Login-Rate-Limit: X Fehlversuche pro IP → Y Minuten Sperre
// ---------------------------------------------------------------------------
const loginAttempts = new Map(); // ip -> { count, lockedUntil }
function checkLoginLock(ip) {
const entry = loginAttempts.get(ip);
if (entry && entry.lockedUntil > Date.now()) {
return Math.ceil((entry.lockedUntil - Date.now()) / 1000);
}
return 0;
}
function registerLoginFail(ip) {
const entry = loginAttempts.get(ip) || { count: 0, lockedUntil: 0 };
entry.count += 1;
if (entry.count >= config.loginMaxAttempts) {
entry.lockedUntil = Date.now() + config.loginLockMinutes * 60 * 1000;
entry.count = 0;
}
loginAttempts.set(ip, entry);
}
function registerLoginOk(ip) {
loginAttempts.delete(ip);
}
// Alte Einträge aufräumen (1×/Stunde)
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of loginAttempts) {
if (entry.lockedUntil <= now && entry.count === 0) loginAttempts.delete(ip);
}
}, 60 * 60 * 1000).unref();
app.post('/api/login', (req, res) => {
const { name, password } = req.body || {};
const ip = req.ip;
const lockSeconds = checkLoginLock(ip);
if (lockSeconds > 0) {
return res
.status(429)
.json({ error: `Zu viele Fehlversuche – bitte in ${lockSeconds} Sekunden erneut versuchen.` });
}
const pw = String(config.appPassword || '');
const valid =
pw &&
password &&
password.length === pw.length &&
crypto.timingSafeEqual(
crypto.createHash('sha256').update(String(password)).digest(),
crypto.createHash('sha256').update(pw).digest()
);
if (!valid) {
registerLoginFail(ip);
return res.status(401).json({ error: 'Falsches Passwort' });
}
registerLoginOk(ip);
const safeName = String(name || 'Nutzer').slice(0, 40);
const token = signToken({ n: safeName, exp: Date.now() + 30 * 24 * 3600 * 1000 });
res.cookie('research_auth', token, cookieOptions(req));
res.json({ name: safeName });
});
app.post('/api/logout', (req, res) => {
res.clearCookie('research_auth', cookieOptions(req));
res.json({ ok: true });
});
// ---------------------------------------------------------------------------
// Sessions & Nachrichten
// ---------------------------------------------------------------------------
app.get('/api/me', requireAuth, (req, res) => res.json({ name: req.user.n }));
app.get('/api/sessions', requireAuth, (req, res) => {
const q = queue.state();
const sessions = db.listSessions().map((s) => ({
...s,
job: s.status === 'running' ? 'running' : q.queued.includes(s.id) ? 'queued' : 'idle',
}));
res.json(sessions);
});
app.post('/api/sessions', requireAuth, (req, res) => {
const session = db.createSession(req.user.n, 'Neue Recherche', getModel(config.model).id);
res.json(session);
});
app.patch('/api/sessions/:id', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session || session.owner_name !== req.user.n) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
const modelId = String(req.body?.model || '');
if (!getModels().some((m) => m.id === modelId)) {
return res.status(400).json({ error: 'Unbekanntes Modell' });
}
db.setSessionModel(session.id, modelId);
res.json(db.getSession(session.id));
});
app.get('/api/models', requireAuth, (req, res) => {
res.json({
default: getModel(config.model).id,
models: getModels().map((m) => ({
id: m.id,
label: m.label,
provider: m.provider,
vision: m.vision,
costNote: m.costNote || null,
})),
});
});
app.get('/api/sessions/:id', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session || (session.owner_name !== req.user.n && !session.share_token)) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
res.json({ session, messages: db.getSessionMessages(session.id) });
});
app.get('/api/sessions/:id/share', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
const url = `/share/${session.share_token}`;
res.json({ url, fullUrl: `${req.protocol}://${req.get('host')}${url}` });
});
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
queue.cancelQueued(session.id);
db.deleteSession(session.id);
res.json({ ok: true });
});
app.get('/api/share/:token', (req, res) => {
const session = db.getSessionByShare(req.params.token);
if (!session) return res.status(404).json({ error: 'Link ungültig' });
res.json({ session, messages: db.getSessionMessages(session.id) });
});
// ---------------------------------------------------------------------------
// Job starten + SSE-Streaming
// ---------------------------------------------------------------------------
function emitToSession(sessionId, type, data) {
queue.events.emit('session-event', sessionId, type, data);
}
app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
const text = String(req.body?.text || '').trim();
const image = typeof req.body?.image === 'string' && req.body.image ? req.body.image : null;
if (!text && !image) return res.status(400).json({ error: 'Leere Nachricht' });
if (image) {
if (!/^data:image\/(jpeg|png|webp);base64,/.test(image)) {
return res.status(400).json({ error: 'Ungültiges Bild (JPEG/PNG/WebP als Data-URL erwartet)' });
}
if (image.length > 4000000) {
return res.status(400).json({ error: 'Bild zu groß (max. ~3 MB)' });
}
const model = getModel(session.model);
if (!model.vision) {
return res.status(400).json({
error: `Das Modell "${model.label}" kann keine Bilder verarbeiten – bitte auf ein Modell mit Bilderkennung wechseln (z.B. GLM 5.3 Flash).`,
});
}
}
if (session.status === 'running' || session.status === 'queued') {
return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' });
}
if (queue.state().running || queue.state().queued.length > 0) {
return res.status(409).json({ error: 'Eine andere Recherche läuft gerade – bitte warten.' });
}
db.createMessage(session.id, { role: 'user', content: text, image });
if (session.title === 'Neue Recherche') {
db.setSessionTitle(session.id, text.slice(0, 70));
}
db.setSessionStatus(session.id, 'queued');
const job = {
sessionId: session.id,
async run() {
db.setSessionStatus(session.id, 'running');
emitToSession(session.id, 'status', { sessionStatus: 'running', jobStatus: 'running' });
const emit = {
token: (t) => emitToSession(session.id, 'token', { text: t }),
thinking: (t) => emitToSession(session.id, 'thinking', { text: t }),
progress: (icon, text) => emitToSession(session.id, 'progress', { icon, text }),
step: (text) => emitToSession(session.id, 'step', { text }),
done: (result) => {
emitToSession(session.id, 'done', result);
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'done' });
sendPush(session, result);
},
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
};
await runResearchJob(
config,
db.getSession(session.id),
text,
emit,
db
);
},
async onError(e) {
console.error('Job-Fehler:', e);
db.setSessionStatus(session.id, 'done');
emitToSession(session.id, 'error', { text: `Fehler: ${e.message}` });
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'error' });
},
};
queue.enqueue(job);
res.status(202).json({ ok: true, status: 'queued' });
});
app.get('/api/sessions/:id/stream', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) return res.status(404).end();
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const send = (type, data) => {
if (res.writableEnded) return;
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
};
const onEvent = (sessionId, type, data) => {
if (sessionId === session.id) send(type, data);
};
queue.events.on('session-event', onEvent);
const q = queue.state();
const jobStatus = session.status === 'running' ? 'running' : q.queued.includes(session.id) ? 'queued' : 'idle';
send('status', { sessionStatus: session.status, jobStatus });
const heartbeat = setInterval(() => send('ping', { t: Date.now() }), 25000);
req.on('close', () => {
clearInterval(heartbeat);
queue.events.off('session-event', onEvent);
});
});
// ---------------------------------------------------------------------------
// Web Push
// ---------------------------------------------------------------------------
webpush.setVapidDetails(
'mailto:research@localhost',
config.vapidPublicKey,
config.vapidPrivateKey
);
app.get('/api/push/vapid-key', (req, res) => res.json({ key: config.vapidPublicKey }));
app.post('/api/push/subscribe', requireAuth, (req, res) => {
const { endpoint, keys } = req.body || {};
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'Ungültige Subscription' });
}
db.upsertPushSub(req.user.n, endpoint, keys);
res.json({ ok: true });
});
function sendPush(session, result) {
const payload = JSON.stringify({
title: '✅ Recherche fertig',
body: `"${session.title}" – Dashboard ist bereit`,
url: `/s/${session.id}`,
});
for (const sub of db.listPushSubs()) {
webpush
.sendNotification(
{ endpoint: sub.endpoint, keys: JSON.parse(sub.keys) },
payload,
{ TTL: 3600 }
)
.then(() => console.log('Push gesendet an', sub.endpoint))
.catch((e) => {
if (e.statusCode === 404 || e.statusCode === 410) {
db.removePushSub(sub.endpoint);
} else {
console.error('Push-Fehler:', e.message);
}
});
}
}
// ---------------------------------------------------------------------------
// Statische Dateien & Dashboard-Dateien
// ---------------------------------------------------------------------------
app.use(
'/dashboards',
express.static(path.join(__dirname, 'dashboards'), {
// Kein Caching: Dashboards werden bei Folge-Nachrichten überschrieben,
// der Browser soll immer die aktuelle Version sehen.
maxAge: 0,
setHeaders: (res, filePath) => {
if (filePath.endsWith('.gpx')) res.setHeader('Content-Type', 'application/gpx+xml');
if (filePath.endsWith('.tour.json')) res.setHeader('Content-Type', 'application/json');
},
})
);
app.use('/manifest.json', express.static(path.join(__dirname, 'public', 'manifest.json')));
app.use('/sw.js', express.static(path.join(__dirname, 'public', 'sw.js')));
app.use('/icons', express.static(path.join(__dirname, 'public', 'icons')));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/share/:token', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'share.html'));
});
app.get('/s/:id', requireAuth, (req, res) => {
res.redirect(`/?session=${req.params.id}`);
});
app.get('/', (req, res) => {
const payload = verifyToken(req.cookies?.research_auth);
if (!payload) return res.sendFile(path.join(__dirname, 'public', 'login.html'));
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// ---------------------------------------------------------------------------
if (!config.apiKey) {
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – DeepSeek-Modelle werden nicht funktionieren.');
}
if (!config.openrouterApiKey) {
console.error('WARNUNG: OPENROUTER_API_KEY fehlt in .env – OpenRouter-Modelle (z.B. GLM 5.3 Flash) werden nicht funktionieren.');
}
if (!config.appPassword) {
console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.');
}
server.listen(config.port, () => {
console.log(`Research WebApp läuft auf http://localhost:${config.port}`);
});
process.on('SIGINT', async () => {
await closeBrowser();
process.exit(0);
});
process.on('SIGTERM', async () => {
await closeBrowser();
process.exit(0);
});