Research Chat: Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright, Docker/Podman)
This commit is contained in:
commit
a0243d494c
23 files changed
+2253
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user