Files
researchChat/server.js
T
nico 8a2bf46b09 Recherche abbrechen + Chats löschen (mit Aufräumen) + Truncation-Fix
- Manueller Abbruch: POST /api/sessions/:id/abort (nur Owner), AbortController
  für laufende Jobs (auch mitten im API-Stream), cancelQueued für wartende,
  ⛔-Button in der Activity-Bar, 'abgebrochen'-Event + History-Eintrag
- Chats löschen: 🗑-Button in der Sidebar mit Bestätigung, Ownership-Check
  (vorher konnte jeder fremde Chats löschen), löscht jetzt auch Messages
  und Dashboard-Dateien (HTML/GPX/tour.json) mit
- Truncation-Fix: finish_reason 'length' wird erkannt (sichtbarer Fehler
  statt leerer Abschluss), max_tokens auf 131072 (GLM-Max), 10-Min-Timeout
  gegen hängende Streams
2026-08-28 07:17:06 +02:00

470 lines
16 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, deleteSessionFiles } = 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 || session.owner_name !== req.user.n) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
queue.cancelQueued(session.id);
queue.abortRunning(session.id);
db.deleteSession(session.id);
deleteSessionFiles(session.id);
res.json({ ok: true });
});
app.post('/api/sessions/:id/abort', 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' });
}
if (queue.queuedBefore(session.id) >= 0) {
// Job wartet noch in der Queue → direkt entfernen
queue.cancelQueued(session.id);
db.setSessionStatus(session.id, 'done');
emitToSession(session.id, 'aborted', {});
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' });
} else {
// Laufender Job → Controller auslösen, der Job räumt selbst auf
queue.abortRunning(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 controller = queue.registerController(session.id);
const job = {
sessionId: session.id,
async run() {
try {
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);
},
aborted: () => {
emitToSession(session.id, 'aborted', {});
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' });
},
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
};
await runResearchJob(
config,
db.getSession(session.id),
text,
emit,
db,
controller.signal
);
} finally {
queue.clearController(session.id);
}
},
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);
});