Research Chat: Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright, Docker/Podman)

This commit is contained in:
nico committed 2026-08-26 16:34:03 +02:00
commit a0243d494c
23 files changed
+2253

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const express = require('express');
const webpush = require('web-push');
const { config } = 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.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();
});
app.post('/api/login', (req, res) => {
const { name, password } = req.body || {};
const pw = String(config.appPassword || '');
if (!pw || !password || password.length !== pw.length) {
return res.status(401).json({ error: 'Falsches Passwort' });
}
const a = crypto.createHash('sha256').update(password).digest();
const b = crypto.createHash('sha256').update(pw).digest();
if (!crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'Falsches Passwort' });
}
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);
res.json(session);
});
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();
if (!text) return res.status(400).json({ error: 'Leere Nachricht' });
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 });
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(
{ apiKey: config.apiKey, model: config.model, thinking: config.thinking, baseUrl: config.baseUrl },
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'), { maxAge: '1h' }));
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 – Recherche wird 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);
});