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

+194
View File
@@ -0,0 +1,194 @@
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const crypto = require('crypto');
const DATA_DIR = path.join(__dirname, '..', 'data');
const DB_PATH = path.join(DATA_DIR, 'research.db');
const fs = require('fs');
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT DEFAULT 'Neue Recherche',
owner_name TEXT NOT NULL,
share_token TEXT UNIQUE,
status TEXT DEFAULT 'idle',
dashboard TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_name TEXT,
tool_call_id TEXT,
tool_calls TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL,
endpoint TEXT UNIQUE NOT NULL,
keys TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
const now = () => new Date().toISOString();
const uid = () => crypto.randomBytes(12).toString('hex');
const shareToken = () => crypto.randomBytes(16).toString('hex');
// DeepSeek v4 Flash Preise (USD pro 1M Tokens, Peak) – obere Schätzung
const PRICES = { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 };
function computeCost(u) {
return (
((u.cacheHitTokens || 0) * PRICES.inputCacheHit +
(u.cacheMissTokens || 0) * PRICES.inputCacheMiss +
(u.completionTokens || 0) * PRICES.output) /
1e6
);
}
// Migration: Usage-Spalten ergänzen (falls Tabelle älter ist)
{
const cols = db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name);
const add = (name, def) => {
if (!cols.includes(name)) db.exec(`ALTER TABLE sessions ADD COLUMN ${name} ${def}`);
};
add('prompt_tokens', 'INTEGER DEFAULT 0');
add('completion_tokens', 'INTEGER DEFAULT 0');
add('cache_hit_tokens', 'INTEGER DEFAULT 0');
add('cache_miss_tokens', 'INTEGER DEFAULT 0');
add('cost_estimate', 'REAL DEFAULT 0');
}
const stmts = {
createSession: db.prepare(
`INSERT INTO sessions (id, title, owner_name, share_token, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'idle', ?, ?)`
),
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
getSessionByShare: db.prepare(`SELECT * FROM sessions WHERE share_token = ?`),
listSessions: db.prepare(`SELECT * FROM sessions ORDER BY updated_at DESC`),
updateSession: db.prepare(
`UPDATE sessions SET title = ?, status = ?, dashboard = ?, updated_at = ? WHERE id = ?`
),
setSessionStatus: db.prepare(
`UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?`
),
setSessionTitle: db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`),
setSessionDashboard: db.prepare(`UPDATE sessions SET dashboard = ?, updated_at = ? WHERE id = ?`),
addUsage: db.prepare(
`UPDATE sessions SET prompt_tokens = prompt_tokens + ?, completion_tokens = completion_tokens + ?,
cache_hit_tokens = cache_hit_tokens + ?, cache_miss_tokens = cache_miss_tokens + ?,
cost_estimate = cost_estimate + ? WHERE id = ?`
),
deleteSession: db.prepare(`DELETE FROM sessions WHERE id = ?`),
insertMessage: db.prepare(
`INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
),
listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`),
lastMessage: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`),
updateMessageContent: db.prepare(`UPDATE messages SET content = ? WHERE id = ?`),
deleteMessages: db.prepare(`DELETE FROM messages WHERE session_id = ?`),
insertPushSub: db.prepare(
`INSERT INTO push_subscriptions (user_name, endpoint, keys, created_at) VALUES (?, ?, ?, ?)`
),
deletePushSub: db.prepare(`DELETE FROM push_subscriptions WHERE endpoint = ?`),
listPushSubs: db.prepare(`SELECT * FROM push_subscriptions`),
};
function createSession(ownerName, title = 'Neue Recherche') {
const id = uid();
stmts.createSession.run(id, title, ownerName, shareToken(), now(), now());
return stmts.getSession.get(id);
}
function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls }) {
stmts.insertMessage.run(
sessionId,
role,
content ?? null,
toolName ?? null,
toolCallId ?? null,
toolCalls ? JSON.stringify(toolCalls) : null,
now()
);
return stmts.lastMessage.get(sessionId);
}
function getSessionMessages(sessionId) {
return stmts.listMessages.all(sessionId).map((m) => ({
...m,
tool_calls: m.tool_calls ? JSON.parse(m.tool_calls) : null,
}));
}
/** Baut aus dem Chat-Verlauf das Messages-Array für die DeepSeek-API. */
function toApiMessages(sessionId) {
const msgs = getSessionMessages(sessionId);
const out = [];
for (const m of msgs) {
if (m.role === 'user') out.push({ role: 'user', content: m.content });
else if (m.role === 'assistant') {
const msg = { role: 'assistant' };
if (m.tool_calls) msg.tool_calls = m.tool_calls;
if (m.content) msg.content = m.content;
else if (!m.tool_calls) msg.content = '';
out.push(msg);
} else if (m.role === 'tool') {
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: m.content ?? '' });
}
}
return out.slice(-60);
}
module.exports = {
db,
createSession,
createMessage,
getSession: stmts.getSession.get.bind(stmts.getSession),
getSessionByShare: stmts.getSessionByShare.get.bind(stmts.getSessionByShare),
listSessions: stmts.listSessions.all.bind(stmts.listSessions),
setSessionStatus: (id, status) => stmts.setSessionStatus.run(status, now(), id),
setSessionTitle: (id, title) => stmts.setSessionTitle.run(title, now(), id),
setSessionDashboard: (id, dashboard) => stmts.setSessionDashboard.run(dashboard, now(), id),
setSessionUsage: (id, u) =>
stmts.addUsage.run(
u.promptTokens || 0,
u.completionTokens || 0,
u.cacheHitTokens || 0,
u.cacheMissTokens || 0,
computeCost(u),
id
),
computeCost,
deleteSession: (id) => stmts.deleteSession.run(id),
getSessionMessages,
toApiMessages,
upsertPushSub(userName, endpoint, keys) {
try {
stmts.insertPushSub.run(userName, endpoint, JSON.stringify(keys), now());
} catch {
stmts.deletePushSub.run(endpoint);
stmts.insertPushSub.run(userName, endpoint, JSON.stringify(keys), now());
}
},
removePushSub: stmts.deletePushSub.run.bind(stmts.deletePushSub),
listPushSubs: stmts.listPushSubs.all.bind(stmts.listPushSubs),
};