Compare commits
6
Commits
eee59363d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b740ce1316 | ||
|
|
12f01b86d6 | ||
|
|
ab4f8e8570 | ||
|
|
8a2bf46b09 | ||
|
|
0b51bb6142 | ||
|
|
87eb7029f1 |
No files matched your search
+11
-3
@@ -10,13 +10,21 @@ APP_PASSWORD=ein-sicheres-passwort
|
|||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
# Port der App (NGINX proxied auf diesen; in Docker per EXPOSE/PORTS)
|
# Port der App (NGINX proxied auf diesen; in Docker per EXPOSE/PORTS)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
# DeepSeek Modell
|
# DeepSeek Modell (V4.1 Flash, inkl. Vision/Bilderkennung)
|
||||||
MODEL=deepseek-v4-flash
|
MODEL=deepseek-flash
|
||||||
# Thinking-Mode des Modells: enabled | disabled
|
# Thinking-Mode des Modells: enabled | disabled (gilt für DeepSeek)
|
||||||
THINKING=enabled
|
THINKING=enabled
|
||||||
|
# Reasoning-Tiefe: low | high | max
|
||||||
|
DEEPSEEK_REASONING_EFFORT=max
|
||||||
|
# Maximale Antwort-Tokens: nur relevant für eigene Modelle aus der Env (MODEL).
|
||||||
|
# Das eingebaute Modell (DeepSeek V4.1 Flash) nutzt fest 300000 aus der Registry.
|
||||||
|
MAX_TOKENS=64000
|
||||||
# VAPID-Schlüsselpaar für Web Push (wird automatisch generiert)
|
# VAPID-Schlüsselpaar für Web Push (wird automatisch generiert)
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
# Login-Schutz: Fehlversuche pro IP bevor gesperrt wird, und Sperrdauer
|
# Login-Schutz: Fehlversuche pro IP bevor gesperrt wird, und Sperrdauer
|
||||||
LOGIN_MAX_ATTEMPTS=5
|
LOGIN_MAX_ATTEMPTS=5
|
||||||
LOGIN_LOCK_MINUTES=5
|
LOGIN_LOCK_MINUTES=5
|
||||||
|
# Tour-Feature: E-Mail für Nominatim (Fair-Use, optional) und OSRM-Server
|
||||||
|
NOMINATIM_EMAIL=
|
||||||
|
OSRM_URL=https://router.project-osrm.org
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
data/
|
data/
|
||||||
dashboards/*.html
|
dashboards/*.html
|
||||||
|
dashboards/*.gpx
|
||||||
|
dashboards/*.tour.json
|
||||||
|
dashboards/*.jpg
|
||||||
|
dashboards/*.jpeg
|
||||||
|
dashboards/*.png
|
||||||
|
dashboards/*.webp
|
||||||
|
dashboards/*.gif
|
||||||
.env
|
.env
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
# Research Agent – Web-Chat-Oberfläche
|
# Research Agent – Web-Chat-Oberfläche
|
||||||
|
|
||||||
Browser-Chatoberfläche für den Research-Agent: DeepSeek v4 Flash recherchiert per Playwright im
|
Browser-Chatoberfläche für den Research-Agent: DeepSeek V4.1 Flash recherchiert per Playwright im
|
||||||
Internet, verifiziert alle Links und generiert HTML-Dashboards – ohne dass Opencode lokal laufen muss.
|
Internet, verifiziert alle Links und generiert HTML-Dashboards – ohne dass Opencode lokal laufen muss.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- 💬 Chat-Oberfläche mit Live-Fortschritt (SSE): Suchen, Seitenbesuche, Link-Verifizierung in Echtzeit
|
- 💬 Chat-Oberfläche mit Live-Fortschritt (SSE): Suchen, Seitenbesuche, Link-Verifizierung in Echtzeit
|
||||||
- 🔎 Volle Research-Funktion: DuckDuckGo-Suche, Seiten lesen, Links verifizieren, HTML-Dashboard generieren
|
- 🔎 Volle Research-Funktion: DuckDuckGo-Suche, Seiten lesen, Links verifizieren, HTML-Dashboard generieren
|
||||||
|
- 📷 Bild-Upload & Vision: Fotos analysieren, ähnliche Produkte finden und mit lokal gespeicherten Bildern ins Dashboard einbetten
|
||||||
- 🔔 Web-Push-Benachrichtigung, wenn die Recherche fertig ist (auch wenn der Browser zu ist)
|
- 🔔 Web-Push-Benachrichtigung, wenn die Recherche fertig ist (auch wenn der Browser zu ist)
|
||||||
- 📱 PWA: installierbar auf Desktop & Handy (Chrome/Android; iOS 16.4+ für Push)
|
- 📱 PWA: installierbar auf Desktop & Handy (Chrome/Android; iOS 16.4+ für Push)
|
||||||
- 👤 Eine gemeinsame Anmeldung (Name + Passwort), Sessions sind dem Namen zugeordnet
|
- 👤 Eine gemeinsame Anmeldung (Name + Passwort), Sessions sind dem Namen zugeordnet
|
||||||
@@ -34,7 +35,7 @@ erzeugt und in die `.env` geschrieben.
|
|||||||
| `DEEPSEEK_API_KEY` | ja | Key von https://platform.deepseek.com/api_keys |
|
| `DEEPSEEK_API_KEY` | ja | Key von https://platform.deepseek.com/api_keys |
|
||||||
| `APP_PASSWORD` | ja | Gemeinsames Login-Passwort |
|
| `APP_PASSWORD` | ja | Gemeinsames Login-Passwort |
|
||||||
| `PORT` | nein | Standard 3000 |
|
| `PORT` | nein | Standard 3000 |
|
||||||
| `MODEL` | nein | Standard `deepseek-v4-flash` |
|
| `MODEL` | nein | Standard `deepseek-flash` |
|
||||||
| `THINKING` | nein | `enabled` (Standard) oder `disabled` |
|
| `THINKING` | nein | `enabled` (Standard) oder `disabled` |
|
||||||
| `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar |
|
| `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar |
|
||||||
| `LOGIN_MAX_ATTEMPTS` | nein | Fehlversuche pro IP vor Sperre (Standard 5) |
|
| `LOGIN_MAX_ATTEMPTS` | nein | Fehlversuche pro IP vor Sperre (Standard 5) |
|
||||||
@@ -191,5 +192,5 @@ researchChat/
|
|||||||
- Nur **eine** Recherche läuft gleichzeitig; weitere Anfragen zeigen „Recherche läuft…“
|
- Nur **eine** Recherche läuft gleichzeitig; weitere Anfragen zeigen „Recherche läuft…“
|
||||||
- DuckDuckGo blockiert nach ~4-5 Suchen: Der Agent wechselt automatisch auf Fallback-Suchmaschinen
|
- DuckDuckGo blockiert nach ~4-5 Suchen: Der Agent wechselt automatisch auf Fallback-Suchmaschinen
|
||||||
- Der Browser-Context bleibt pro Server-Lauf erhalten (weniger CAPTCHAs durch stabile Session-Cookies)
|
- Der Browser-Context bleibt pro Server-Lauf erhalten (weniger CAPTCHAs durch stabile Session-Cookies)
|
||||||
- API-Kosten: DeepSeek v4 Flash kostet ~$0,44/M Input- und ~$0,66/M Output-Token – eine Recherche kostet Cent-Beträge
|
- API-Kosten: DeepSeek V4.1 Flash kostet ~$0,30/M Input- (Cache-Miss) und ~$1,20/M Output-Token (Peak; Off-Peak halb so teuer) – eine Recherche kostet Cent-Beträge
|
||||||
- Auf SELinux-Systemen (Bazzite/Fedora Atomic) sind die Volume-Mounts mit `:Z` markiert – Docker ignoriert das Flag, dort reicht die Compose-Datei unverändert
|
- Auf SELinux-Systemen (Bazzite/Fedora Atomic) sind die Volume-Mounts mit `:Z` markiert – Docker ignoriert das Flag, dort reicht die Compose-Datei unverändert
|
||||||
+50
-2
@@ -52,14 +52,62 @@ const config = {
|
|||||||
port: Number(process.env.PORT || env.PORT || 3000),
|
port: Number(process.env.PORT || env.PORT || 3000),
|
||||||
apiKey: process.env.DEEPSEEK_API_KEY || env.DEEPSEEK_API_KEY || '',
|
apiKey: process.env.DEEPSEEK_API_KEY || env.DEEPSEEK_API_KEY || '',
|
||||||
appPassword: process.env.APP_PASSWORD || env.APP_PASSWORD || '',
|
appPassword: process.env.APP_PASSWORD || env.APP_PASSWORD || '',
|
||||||
model: process.env.MODEL || env.MODEL || 'deepseek-v4-flash',
|
model: process.env.MODEL || env.MODEL || 'deepseek-flash',
|
||||||
thinking: process.env.THINKING || env.THINKING || 'enabled',
|
thinking: process.env.THINKING || env.THINKING || 'enabled',
|
||||||
|
maxTokens: Number(process.env.MAX_TOKENS || env.MAX_TOKENS || 64000),
|
||||||
baseUrl: process.env.DEEPSEEK_BASE_URL || env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
|
baseUrl: process.env.DEEPSEEK_BASE_URL || env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
|
||||||
loginMaxAttempts: Number(process.env.LOGIN_MAX_ATTEMPTS || env.LOGIN_MAX_ATTEMPTS || 5),
|
loginMaxAttempts: Number(process.env.LOGIN_MAX_ATTEMPTS || env.LOGIN_MAX_ATTEMPTS || 5),
|
||||||
loginLockMinutes: Number(process.env.LOGIN_LOCK_MINUTES || env.LOGIN_LOCK_MINUTES || 5),
|
loginLockMinutes: Number(process.env.LOGIN_LOCK_MINUTES || env.LOGIN_LOCK_MINUTES || 5),
|
||||||
|
nominatimEmail: process.env.NOMINATIM_EMAIL || env.NOMINATIM_EMAIL || '',
|
||||||
|
osrmUrl: process.env.OSRM_URL || env.OSRM_URL || 'https://router.project-osrm.org',
|
||||||
sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')),
|
sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')),
|
||||||
vapidPublicKey: vapid.publicKey,
|
vapidPublicKey: vapid.publicKey,
|
||||||
vapidPrivateKey: vapid.privateKey,
|
vapidPrivateKey: vapid.privateKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { config };
|
const envOr = (key, def) => process.env[key] || env[key] || def;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modell-Registry: DeepSeek V4.1 Flash (Text + Vision/Bilderkennung)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MODELS = [
|
||||||
|
{
|
||||||
|
id: 'deepseek-flash',
|
||||||
|
label: 'DeepSeek V4.1 Flash',
|
||||||
|
provider: 'deepseek',
|
||||||
|
vision: true,
|
||||||
|
// Max. Output laut Doku: 384K
|
||||||
|
maxTokens: 300000,
|
||||||
|
reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
|
||||||
|
// Liefer-Runde des Dashboards: Denken komplett aus, Budget gehört dem HTML
|
||||||
|
deliveryEffort: 'none',
|
||||||
|
// Peak-Preise laut Docs (USD pro 1M Tokens; Off-Peak ist halb so teuer)
|
||||||
|
prices: { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Registry inkl. benutzerdefiniertem Modell aus der Env (MODEL), falls nicht bekannt. */
|
||||||
|
function getModels() {
|
||||||
|
if (MODELS.some((m) => m.id === config.model)) return MODELS;
|
||||||
|
return [
|
||||||
|
...MODELS,
|
||||||
|
{
|
||||||
|
id: config.model,
|
||||||
|
label: config.model,
|
||||||
|
provider: 'deepseek',
|
||||||
|
vision: false,
|
||||||
|
maxTokens: config.maxTokens,
|
||||||
|
reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
|
||||||
|
prices: { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modell per ID auflösen (Fallback: Default-Modell aus Env). */
|
||||||
|
function getModel(id) {
|
||||||
|
const models = getModels();
|
||||||
|
return models.find((m) => m.id === id) || models.find((m) => m.id === config.model) || models[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { config, MODELS, getModels, getModel };
|
||||||
@@ -49,14 +49,14 @@ const now = () => new Date().toISOString();
|
|||||||
const uid = () => crypto.randomBytes(12).toString('hex');
|
const uid = () => crypto.randomBytes(12).toString('hex');
|
||||||
const shareToken = () => crypto.randomBytes(16).toString('hex');
|
const shareToken = () => crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
// DeepSeek v4 Flash Preise (USD pro 1M Tokens, Peak) – obere Schätzung
|
// DeepSeek V4.1 Flash Preise (USD pro 1M Tokens, Peak) – obere Schätzung.
|
||||||
const PRICES = { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 };
|
const PRICES = { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 };
|
||||||
|
|
||||||
function computeCost(u) {
|
function computeCost(u, prices = PRICES) {
|
||||||
return (
|
return (
|
||||||
((u.cacheHitTokens || 0) * PRICES.inputCacheHit +
|
((u.cacheHitTokens || 0) * prices.inputCacheHit +
|
||||||
(u.cacheMissTokens || 0) * PRICES.inputCacheMiss +
|
(u.cacheMissTokens || 0) * prices.inputCacheMiss +
|
||||||
(u.completionTokens || 0) * PRICES.output) /
|
(u.completionTokens || 0) * prices.output) /
|
||||||
1e6
|
1e6
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,12 +72,29 @@ function computeCost(u) {
|
|||||||
add('cache_hit_tokens', 'INTEGER DEFAULT 0');
|
add('cache_hit_tokens', 'INTEGER DEFAULT 0');
|
||||||
add('cache_miss_tokens', 'INTEGER DEFAULT 0');
|
add('cache_miss_tokens', 'INTEGER DEFAULT 0');
|
||||||
add('cost_estimate', 'REAL DEFAULT 0');
|
add('cost_estimate', 'REAL DEFAULT 0');
|
||||||
|
add('model', 'TEXT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: Messages-Spalten ergänzen (Reasoning-Roundtrip + Bild-Upload)
|
||||||
|
{
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(messages)`).all().map((c) => c.name);
|
||||||
|
const add = (name, def) => {
|
||||||
|
if (!cols.includes(name)) db.exec(`ALTER TABLE messages ADD COLUMN ${name} ${def}`);
|
||||||
|
};
|
||||||
|
add('reasoning', 'TEXT');
|
||||||
|
add('image', 'TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Legacy-/OpenRouter-Modell-IDs auf den aktuellen Namen umstellen
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions SET model = 'deepseek-flash'
|
||||||
|
WHERE model IN ('deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'z-ai/glm-5.3-flash')`
|
||||||
|
).run();
|
||||||
|
|
||||||
const stmts = {
|
const stmts = {
|
||||||
createSession: db.prepare(
|
createSession: db.prepare(
|
||||||
`INSERT INTO sessions (id, title, owner_name, share_token, status, created_at, updated_at)
|
`INSERT INTO sessions (id, title, owner_name, share_token, status, model, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 'idle', ?, ?)`
|
VALUES (?, ?, ?, ?, 'idle', ?, ?, ?)`
|
||||||
),
|
),
|
||||||
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
|
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
|
||||||
getSessionByShare: db.prepare(`SELECT * FROM sessions WHERE share_token = ?`),
|
getSessionByShare: db.prepare(`SELECT * FROM sessions WHERE share_token = ?`),
|
||||||
@@ -90,6 +107,7 @@ const stmts = {
|
|||||||
),
|
),
|
||||||
setSessionTitle: db.prepare(`UPDATE sessions SET title = ?, 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 = ?`),
|
setSessionDashboard: db.prepare(`UPDATE sessions SET dashboard = ?, updated_at = ? WHERE id = ?`),
|
||||||
|
setSessionModel: db.prepare(`UPDATE sessions SET model = ?, updated_at = ? WHERE id = ?`),
|
||||||
addUsage: db.prepare(
|
addUsage: db.prepare(
|
||||||
`UPDATE sessions SET prompt_tokens = prompt_tokens + ?, completion_tokens = completion_tokens + ?,
|
`UPDATE sessions SET prompt_tokens = prompt_tokens + ?, completion_tokens = completion_tokens + ?,
|
||||||
cache_hit_tokens = cache_hit_tokens + ?, cache_miss_tokens = cache_miss_tokens + ?,
|
cache_hit_tokens = cache_hit_tokens + ?, cache_miss_tokens = cache_miss_tokens + ?,
|
||||||
@@ -98,10 +116,11 @@ const stmts = {
|
|||||||
deleteSession: db.prepare(`DELETE FROM sessions WHERE id = ?`),
|
deleteSession: db.prepare(`DELETE FROM sessions WHERE id = ?`),
|
||||||
|
|
||||||
insertMessage: db.prepare(
|
insertMessage: db.prepare(
|
||||||
`INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, created_at)
|
`INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, reasoning, image, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
),
|
),
|
||||||
listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`),
|
listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`),
|
||||||
|
listStaleSessions: db.prepare(`SELECT id FROM sessions WHERE status IN ('running', 'queued')`),
|
||||||
lastMessage: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`),
|
lastMessage: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`),
|
||||||
updateMessageContent: db.prepare(`UPDATE messages SET content = ? WHERE id = ?`),
|
updateMessageContent: db.prepare(`UPDATE messages SET content = ? WHERE id = ?`),
|
||||||
deleteMessages: db.prepare(`DELETE FROM messages WHERE session_id = ?`),
|
deleteMessages: db.prepare(`DELETE FROM messages WHERE session_id = ?`),
|
||||||
@@ -113,13 +132,13 @@ const stmts = {
|
|||||||
listPushSubs: db.prepare(`SELECT * FROM push_subscriptions`),
|
listPushSubs: db.prepare(`SELECT * FROM push_subscriptions`),
|
||||||
};
|
};
|
||||||
|
|
||||||
function createSession(ownerName, title = 'Neue Recherche') {
|
function createSession(ownerName, title = 'Neue Recherche', model = null) {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
stmts.createSession.run(id, title, ownerName, shareToken(), now(), now());
|
stmts.createSession.run(id, title, ownerName, shareToken(), model, now(), now());
|
||||||
return stmts.getSession.get(id);
|
return stmts.getSession.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls }) {
|
function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls, reasoning, image }) {
|
||||||
stmts.insertMessage.run(
|
stmts.insertMessage.run(
|
||||||
sessionId,
|
sessionId,
|
||||||
role,
|
role,
|
||||||
@@ -127,6 +146,8 @@ function createMessage(sessionId, { role, content, toolName, toolCallId, toolCal
|
|||||||
toolName ?? null,
|
toolName ?? null,
|
||||||
toolCallId ?? null,
|
toolCallId ?? null,
|
||||||
toolCalls ? JSON.stringify(toolCalls) : null,
|
toolCalls ? JSON.stringify(toolCalls) : null,
|
||||||
|
reasoning ?? null,
|
||||||
|
image ?? null,
|
||||||
now()
|
now()
|
||||||
);
|
);
|
||||||
return stmts.lastMessage.get(sessionId);
|
return stmts.lastMessage.get(sessionId);
|
||||||
@@ -139,23 +160,83 @@ function getSessionMessages(sessionId) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Baut aus dem Chat-Verlauf das Messages-Array für die DeepSeek-API. */
|
const API_HISTORY_MAX = 60;
|
||||||
function toApiMessages(sessionId) {
|
|
||||||
|
/**
|
||||||
|
* Bereinigt den Verlauf für die API: entfernt unvollständige Tool-Gruppen
|
||||||
|
* (assistant-Tool-Call ohne passende tool-Antwort, z.B. nach einem abgebrochenen
|
||||||
|
* Job) und kürzt auf die letzten API_HISTORY_MAX Nachrichten, ohne jemals mitten
|
||||||
|
* in einer Tool-Gruppe zu schneiden. Verwaiste tool-Nachrichten lehnt die
|
||||||
|
* DeepSeek-API sonst mit 400 ab ("Messages with role 'tool' must be a response
|
||||||
|
* to a preceding message with 'tool_calls'").
|
||||||
|
*/
|
||||||
|
function sanitizeHistory(out, max = API_HISTORY_MAX) {
|
||||||
|
const resolved = new Set();
|
||||||
|
const withCalls = [];
|
||||||
|
const owner = new Array(out.length).fill(-1);
|
||||||
|
let cur = -1;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const m = out[i];
|
||||||
|
if (m.role === 'assistant' && m.tool_calls) {
|
||||||
|
withCalls.push(i);
|
||||||
|
cur = i;
|
||||||
|
} else if (m.role === 'tool') {
|
||||||
|
owner[i] = cur;
|
||||||
|
if (m.tool_call_id) resolved.add(m.tool_call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomplete = new Set(
|
||||||
|
withCalls.filter((i) => (out[i].tool_calls || []).some((tc) => !resolved.has(tc.id)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const kept = [];
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
if (incomplete.has(i)) continue;
|
||||||
|
if (out[i].role === 'tool' && incomplete.has(owner[i])) continue;
|
||||||
|
kept.push(out[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kept.length <= max) return kept;
|
||||||
|
let start = kept.length - max;
|
||||||
|
while (start < kept.length && kept[start].role === 'tool') start++;
|
||||||
|
return kept.slice(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus dem Chat-Verlauf das Messages-Array für die Modell-API.
|
||||||
|
* @param {object} opts { provider, vision } – steuert Bild-Content (Vision-Modelle)
|
||||||
|
* und den reasoning_content-Roundtrip (Pflicht bei DeepSeek mit tools).
|
||||||
|
*/
|
||||||
|
function toApiMessages(sessionId, opts = {}) {
|
||||||
|
const { provider = 'deepseek', vision = false } = opts;
|
||||||
const msgs = getSessionMessages(sessionId);
|
const msgs = getSessionMessages(sessionId);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const m of msgs) {
|
for (const m of msgs) {
|
||||||
if (m.role === 'user') out.push({ role: 'user', content: m.content });
|
if (m.role === 'user') {
|
||||||
else if (m.role === 'assistant') {
|
const msg = { role: 'user' };
|
||||||
|
if (m.image && vision) {
|
||||||
|
msg.content = [
|
||||||
|
{ type: 'text', text: m.content || '' },
|
||||||
|
{ type: 'image_url', image_url: { url: m.image } },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
msg.content = m.content;
|
||||||
|
}
|
||||||
|
out.push(msg);
|
||||||
|
} else if (m.role === 'assistant') {
|
||||||
const msg = { role: 'assistant' };
|
const msg = { role: 'assistant' };
|
||||||
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
||||||
if (m.content) msg.content = m.content;
|
if (m.content) msg.content = m.content;
|
||||||
else if (!m.tool_calls) msg.content = '';
|
else if (!m.tool_calls) msg.content = '';
|
||||||
|
// DeepSeek verlangt bei tools-Requests den Roundtrip des Reasoning-Texts
|
||||||
|
if (provider === 'deepseek' && m.reasoning) msg.reasoning_content = m.reasoning;
|
||||||
out.push(msg);
|
out.push(msg);
|
||||||
} else if (m.role === 'tool') {
|
} else if (m.role === 'tool') {
|
||||||
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: m.content ?? '' });
|
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: m.content ?? '' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out.slice(-60);
|
return sanitizeHistory(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -168,17 +249,44 @@ module.exports = {
|
|||||||
setSessionStatus: (id, status) => stmts.setSessionStatus.run(status, now(), id),
|
setSessionStatus: (id, status) => stmts.setSessionStatus.run(status, now(), id),
|
||||||
setSessionTitle: (id, title) => stmts.setSessionTitle.run(title, now(), id),
|
setSessionTitle: (id, title) => stmts.setSessionTitle.run(title, now(), id),
|
||||||
setSessionDashboard: (id, dashboard) => stmts.setSessionDashboard.run(dashboard, now(), id),
|
setSessionDashboard: (id, dashboard) => stmts.setSessionDashboard.run(dashboard, now(), id),
|
||||||
setSessionUsage: (id, u) =>
|
setSessionModel: (id, model) => stmts.setSessionModel.run(model, now(), id),
|
||||||
|
setSessionUsage: (id, u, prices) =>
|
||||||
stmts.addUsage.run(
|
stmts.addUsage.run(
|
||||||
u.promptTokens || 0,
|
u.promptTokens || 0,
|
||||||
u.completionTokens || 0,
|
u.completionTokens || 0,
|
||||||
u.cacheHitTokens || 0,
|
u.cacheHitTokens || 0,
|
||||||
u.cacheMissTokens || 0,
|
u.cacheMissTokens || 0,
|
||||||
computeCost(u),
|
computeCost(u, prices),
|
||||||
id
|
id
|
||||||
),
|
),
|
||||||
computeCost,
|
computeCost,
|
||||||
deleteSession: (id) => stmts.deleteSession.run(id),
|
deleteSession: (id) => {
|
||||||
|
stmts.deleteMessages.run(id);
|
||||||
|
stmts.deleteSession.run(id);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Setzt nach einem Server-Neustart alle Sessions zurück, die im alten
|
||||||
|
* Prozess noch als 'running'/'queued' festhingen (Jobs existieren im
|
||||||
|
* Speicher nicht mehr). Gibt die Anzahl zurück.
|
||||||
|
*/
|
||||||
|
markStaleJobs() {
|
||||||
|
const stale = stmts.listStaleSessions.all();
|
||||||
|
for (const s of stale) {
|
||||||
|
stmts.setSessionStatus.run('done', now(), s.id);
|
||||||
|
stmts.insertMessage.run(
|
||||||
|
s.id,
|
||||||
|
'assistant',
|
||||||
|
'⛔ Recherche wurde durch einen Server-Neustart abgebrochen.',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return stale.length;
|
||||||
|
},
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
toApiMessages,
|
toApiMessages,
|
||||||
upsertPushSub(userName, endpoint, keys) {
|
upsertPushSub(userName, endpoint, keys) {
|
||||||
|
|||||||
+28
-1
@@ -6,6 +6,33 @@ events.setMaxListeners(100);
|
|||||||
let queue = [];
|
let queue = [];
|
||||||
let running = false;
|
let running = false;
|
||||||
|
|
||||||
|
// Abort-Controller für laufende Jobs (für manuellen Abbruch)
|
||||||
|
const controllers = new Map();
|
||||||
|
|
||||||
|
/** Abort-Controller für eine Session registrieren (einmal pro Job). */
|
||||||
|
function registerController(sessionId) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllers.set(sessionId, controller);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laufenden Job einer Session abbrechen. */
|
||||||
|
function abortRunning(sessionId) {
|
||||||
|
const controller = controllers.get(sessionId);
|
||||||
|
if (controller) controller.abort();
|
||||||
|
controllers.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Läuft für diese Session gerade ein Job im Speicher? */
|
||||||
|
function hasController(sessionId) {
|
||||||
|
return controllers.has(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Controller nach Job-Ende entfernen. */
|
||||||
|
function clearController(sessionId) {
|
||||||
|
controllers.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Einen Job anmelden. Läuft sofort, wenn nichts anderes läuft, sonst wartet er. */
|
/** Einen Job anmelden. Läuft sofort, wenn nichts anderes läuft, sonst wartet er. */
|
||||||
function enqueue(job) {
|
function enqueue(job) {
|
||||||
queue.push(job);
|
queue.push(job);
|
||||||
@@ -45,4 +72,4 @@ function state() {
|
|||||||
return { running, queued: queue.map((j) => j.sessionId) };
|
return { running, queued: queue.map((j) => j.sessionId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state };
|
module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state, registerController, abortRunning, hasController, clearController };
|
||||||
+386
-39
@@ -1,4 +1,5 @@
|
|||||||
const tools = require('./tools');
|
const tools = require('./tools');
|
||||||
|
const { getModel } = require('./config');
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensberater. Deine Aufgabe: Nimm eine Recherche-Frage an, recherchiere gründlich mit den bereitgestellten Tools, verifiziere ALLE Links und erstelle ein schönes HTML-Dashboard. Du bist KEIN Link-Sammler, sondern Analyst, Berater und Horizonterweiterer.
|
const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensberater. Deine Aufgabe: Nimm eine Recherche-Frage an, recherchiere gründlich mit den bereitgestellten Tools, verifiziere ALLE Links und erstelle ein schönes HTML-Dashboard. Du bist KEIN Link-Sammler, sondern Analyst, Berater und Horizonterweiterer.
|
||||||
|
|
||||||
@@ -16,7 +17,30 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber
|
|||||||
4. VERIFIZIERE JEDEN Link, der ins Dashboard kommt, mit dem verify-Tool (Seite lädt UND Inhalt passt zum Kontext). Nur verifizierte Links kommen ins Dashboard! Wenn ein Link nicht passt, suche den richtigen nach.
|
4. VERIFIZIERE JEDEN Link, der ins Dashboard kommt, mit dem verify-Tool (Seite lädt UND Inhalt passt zum Kontext). Nur verifizierte Links kommen ins Dashboard! Wenn ein Link nicht passt, suche den richtigen nach.
|
||||||
5. STRUKTURIERE die Ergebnisse in logische Kategorien (nach Plattform, Typ, Reife oder Einfachheit).
|
5. STRUKTURIERE die Ergebnisse in logische Kategorien (nach Plattform, Typ, Reife oder Einfachheit).
|
||||||
6. ANALYSIERE: Schreibe eine ausführliche Einleitung mit 6 Absätzen: (1) Überblick, (2) Horizonterweiterung - was du außerdem herausgefunden hast, (3) Einschätzung der Optionen mit Vor-/Nachteilen, (4) konkrete Empfehlung, (5) Fahrplan / erste Schritte, (6) Fallstricke und Hinweise.
|
6. ANALYSIERE: Schreibe eine ausführliche Einleitung mit 6 Absätzen: (1) Überblick, (2) Horizonterweiterung - was du außerdem herausgefunden hast, (3) Einschätzung der Optionen mit Vor-/Nachteilen, (4) konkrete Empfehlung, (5) Fahrplan / erste Schritte, (6) Fallstricke und Hinweise.
|
||||||
7. GENERIERE das Dashboard mit dem save_dashboard-Tool (Detailstruktur unten) und fasse dann in deiner finalen Antwort das Wichtigste zusammen.
|
7. GENERIERE das Dashboard: Rufe das save_dashboard-Tool mit Titel und Dateinamen auf – OHNE HTML (das Tool bestätigt nur und setzt den Dateinamen). Danach ist deine FINALE Antwort das KOMPLETTE Dashboard-HTML in einem Markdown-Codeblock \`\`\`html ... \`\`\` (Detailstruktur unten). Ein kurzer Einleitungssatz vor dem Codeblock ist erlaubt, danach endet deine Antwort. Kein weiterer Tool-Aufruf nötig.
|
||||||
|
|
||||||
|
## Tour-Planung (PFLICHT bei Tour-Anfragen!)
|
||||||
|
Erkennst du den Wunsch nach einer Route/Tour/Städtebesichtigung (z.B. "plane eine Tour", "Route durch", "Städtebummel", "was sollte ich in X sehen"), gilt:
|
||||||
|
- **Du MUSST die Tools geocode und save_tour verwenden.** Erfinde NIEMALS Koordinaten, Distanzen oder Gehzeiten selbst!
|
||||||
|
- Ablauf: (1) Orte wie gewohnt recherchieren (verifizierte Quellen), (2) sinnvolle Besuchsreihenfolge festlegen, (3) JEDEN Stopp mit geocode geokodieren, (4) save_tour mit allen Stopps aufrufen (Name, lat, lon, Beschreibung), (5) Distanz/Gehzeit AUS DEM TOOL-ERGEBNIS übernehmen, (6) Karten-Sektion ins Dashboard bauen, (7) in der finalen Antwort GPX-Download und Google-Maps-Link nennen.
|
||||||
|
|
||||||
|
### "🗺️ Tour-Karte"-Sektion im Dashboard (nur wenn save_tour gelaufen ist)
|
||||||
|
- Leaflet via CDN einbinden: <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> und <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> (zusätzlich zu Tailwind CDN)
|
||||||
|
- Karten-Div mit fester Höhe (z.B. style="height: 450px")
|
||||||
|
- Tiles: KEIN API-KEY NÖTIG! Nutze AUSSCHLIESSLICH exakt diese CARTO-Konfiguration (kostenlos, ohne Key, kein Referer-Zwang):
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 20,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>'
|
||||||
|
}).addTo(map);
|
||||||
|
- VERBOTEN sind Anbieter mit API-Key (Mapbox, MapTiler, Thunderforest, Google Maps, Here) und tile.openstreetmap.org – CARTO ist die einzige erlaubte Kartenquelle. Falls du irgendwo einen "API key" sehen solltest, ist es falsch!
|
||||||
|
- Die Stopps (mit lat/lon) INLINE als JavaScript-Objekt ins HTML schreiben
|
||||||
|
- Die Routen-Geometrie per fetch() aus der tour.json laden (URL hast du vom save_tour-Ergebnis). Fallback, wenn fetch fehlschlägt: Polylinie direkt durch die Stopps zeichnen
|
||||||
|
- Nummerierte Marker (CircleMarker mit Zahl), Polylinie (blau), map.fitBounds(...) über alle Punkte
|
||||||
|
- Buttons über der Karte: "📍 Route in Google Maps öffnen" (googleMapsUrl aus dem save_tour-Ergebnis, target=_blank) und "🗺️ GPX für OsmAnd herunterladen" (gpxUrl, mit download-Attribut)
|
||||||
|
- Unter der Karte die Stopp-Liste: Nummer, Name, Beschreibung, "Hier hin navigieren"-Link (navUrl pro Stopp)
|
||||||
|
- Header-Zeile mit Distanz (km) und Gehzeit (min), wenn vorhanden
|
||||||
|
- Hinweis: "GPX in OsmAnd importieren: Datei herunterladen → Menü → Meine Orte → Importieren"
|
||||||
|
|
||||||
## Dashboard-Struktur (HTML, komplette Standalone-Datei)
|
## Dashboard-Struktur (HTML, komplette Standalone-Datei)
|
||||||
- <html lang="de">, <script src="https://cdn.tailwindcss.com"></script> im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen)
|
- <html lang="de">, <script src="https://cdn.tailwindcss.com"></script> im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen)
|
||||||
@@ -28,10 +52,11 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber
|
|||||||
|
|
||||||
## Regeln
|
## Regeln
|
||||||
- JEDER Link im Dashboard MUSS mit verify() verifiziert sein - füge bei den Karten einen "✓ Verifiziert"-Badge hinzu
|
- JEDER Link im Dashboard MUSS mit verify() verifiziert sein - füge bei den Karten einen "✓ Verifiziert"-Badge hinzu
|
||||||
|
- Erfinde NIEMALS Koordinaten, Distanzen oder Gehzeiten – bei Touren kommen die ausschließlich aus geocode() und save_tour()
|
||||||
- Ziele auf 8-15 verifizierte Ressourcen pro Thema
|
- Ziele auf 8-15 verifizierte Ressourcen pro Thema
|
||||||
- Bei Login-Walls: old.reddit.com statt reddit.com, nitter.net statt twitter.com; wenn eine Seite blockiert, notiere das und nimm einen anderen Link
|
- Bei Login-Walls: old.reddit.com statt reddit.com, nitter.net statt twitter.com; wenn eine Seite blockiert, notiere das und nimm einen anderen Link
|
||||||
- Die finale Antwort ist eine deutsche Zusammenfassung (max. 300 Wörter): wichtigste Erkenntnisse + Link zum Dashboard. Details gehören ins Dashboard, nicht in die Antwort.
|
- Deine finale Antwort nach dem save_dashboard-Tool-Aufruf ist das komplette Dashboard-HTML in einem \`\`\`html\`\`\`-Codeblock (eine Text-Zusammenfassung ist dann NICHT nötig – Details gehören ins Dashboard).
|
||||||
- Bei Folge-Nachrichten in einer bestehenden Session: Recherchiere die neue Anforderung und überschreibe das bestehende Dashboard mit dem save_dashboard-Tool, indem du DENSELBEN Dateinamen wie zuvor verwendest.
|
- Bei Folge-Nachrichten in einer bestehenden Session: Recherchiere die neue Anforderung und überschreibe das bestehende Dashboard, indem du save_dashboard mit DENSELBEN Dateinamen wie zuvor aufrufst (OHNE HTML) und das komplette aktualisierte HTML in deiner finalen Antwort im \`\`\`html\`\`\`-Codeblock sendest.
|
||||||
- Du darfst KEINE anderen URLs erfinden. Nutze ausschließlich Ergebnisse aus search/browse/verify.`;
|
- Du darfst KEINE anderen URLs erfinden. Nutze ausschließlich Ergebnisse aus search/browse/verify.`;
|
||||||
|
|
||||||
const TOOL_DEFINITIONS = [
|
const TOOL_DEFINITIONS = [
|
||||||
@@ -55,7 +80,7 @@ const TOOL_DEFINITIONS = [
|
|||||||
function: {
|
function: {
|
||||||
name: 'browse',
|
name: 'browse',
|
||||||
description:
|
description:
|
||||||
'Besucht eine URL und liest den sichtbaren Text der Seite (Titel + Inhalt). Nutze das zum Lesen von Artikeln, Projektseiten und Fallback-Suchmaschinen (Startpage: https://www.startpage.com/do/dsearch?query=..., Google: https://www.google.com/search?q=...).',
|
'Besucht eine URL, liest den sichtbaren Text und lädt Produktbilder der Seite lokal herunter. Das Feld `images` enthält die fertigen Bild-URLs (z.B. /dashboards/...) fürs Dashboard. Nutze das zum Lesen von Artikeln, Projektseiten und Fallback-Suchmaschinen (Startpage: https://www.startpage.com/do/dsearch?query=..., Google: https://www.google.com/search?q=...).',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -85,20 +110,86 @@ const TOOL_DEFINITIONS = [
|
|||||||
function: {
|
function: {
|
||||||
name: 'save_dashboard',
|
name: 'save_dashboard',
|
||||||
description:
|
description:
|
||||||
'Speichert das fertige HTML-Dashboard als Datei auf dem Server und liefert die URL zurück. Übergib die KOMPLETTE standalone HTML-Datei (mit Tailwind CDN, deutsch, verifizierte Links mit Badges, Einleitung mit Analyse). Nutze bei Folge-Nachrichten den bisherigen Dateinamen, um das Dashboard zu aktualisieren.',
|
'Bereitet das Speichern des HTML-Dashboards vor. Übergib NUR Titel und Dateinamen (OHNE HTML). Danach sendest du das KOMPLETTE HTML in deiner finalen Antwort in einem ```html```-Codeblock. Bei Folge-Nachrichten denselben Dateinamen wie zuvor verwenden, um das Dashboard zu aktualisieren.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: 'string', description: 'Titel des Dashboards, z.B. "Fediverse-Alternativen zu Instagram"' },
|
title: { type: 'string', description: 'Titel des Dashboards, z.B. "Fediverse-Alternativen zu Instagram"' },
|
||||||
filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "fediverse-alternativen". Für Updates denselben Namen wie vorher verwenden.' },
|
filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "fediverse-alternativen". Für Updates denselben Namen wie vorher verwenden.' },
|
||||||
html: { type: 'string', description: 'Die komplette standalone HTML-Datei des Dashboards' },
|
html: { type: 'string', description: 'Optional: komplettes HTML direkt übergeben – nur für kleine Dashboards; bevorzugt ist der Weg über die finale Antwort (```html```-Codeblock).' },
|
||||||
},
|
},
|
||||||
required: ['title', 'filename', 'html'],
|
required: ['title', 'filename'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'geocode',
|
||||||
|
description:
|
||||||
|
'Wandelt einen Ortsnamen in Koordinaten um (Nominatim/OpenStreetMap, kostenlos). Nutze das für jeden Stopp einer Tour, z.B. "Brandenburger Tor Berlin". Liefert lat/lon. Maximal 1 Request pro Sekunde.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Ortsangabe, z.B. "Brandenburger Tor Berlin" oder bereits Koordinaten "52.5163, 13.3777"' },
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'save_tour',
|
||||||
|
description:
|
||||||
|
'Erstellt aus einer Liste von Stopps (Name + Koordinaten + Beschreibung) eine komplette Tour: GPX-Datei für OsmAnd, tour.json für die Karte im Dashboard, Google-Maps-Routen-URL mit allen Stopps und Fuß-Navigation pro Stopp. Die Route wird über OSRM als echte Geh-Route berechnet (Fallback: Luftlinie). IMMER vorher jeden Stopp mit geocode() geokodieren!',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Titel der Tour, z.B. "Tagestour Prag"' },
|
||||||
|
filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "prag-tagestour"' },
|
||||||
|
stops: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Stopps in Besuchsreihenfolge (Start bis Ende)',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'Name des Ortes' },
|
||||||
|
lat: { type: 'number', description: 'Breitengrad' },
|
||||||
|
lon: { type: 'number', description: 'Längengrad' },
|
||||||
|
description: { type: 'string', description: 'Kurze Beschreibung (2-3 Sätze)' },
|
||||||
|
},
|
||||||
|
required: ['name', 'lat', 'lon'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['title', 'filename', 'stops'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Nur für Vision-Modelle: Seite fotografieren und visuell beurteilen. */
|
||||||
|
const SCREENSHOT_TOOL = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'screenshot',
|
||||||
|
description:
|
||||||
|
'Macht einen Screenshot einer Seite (JPEG) und liefert das Bild in den Kontext. Nutze das bei bildlastigen Seiten, wenn der Text nicht ausreicht (z.B. Anzeigen-Listen, Produktfotos, Infografiken) oder um Produkte visuell zu vergleichen (Farbe, Form). Maximal ~5 Screenshots pro Session.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', description: 'Die exakte URL der Seite' },
|
||||||
|
full_page: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Volle Seitenlänge erfassen (Standard: nur sichtbarer Bereich). Bei langen Seiten zu groß – dort besser weglassen.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['url'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** Menschlich lesbare Zusammenfassung eines Tool-Ergebnisses für den Chat-Verlauf. */
|
/** Menschlich lesbare Zusammenfassung eines Tool-Ergebnisses für den Chat-Verlauf. */
|
||||||
function summarizeTool(toolName, output) {
|
function summarizeTool(toolName, output) {
|
||||||
let data;
|
let data;
|
||||||
@@ -113,11 +204,25 @@ function summarizeTool(toolName, output) {
|
|||||||
(data.results || []).slice(0, 5).map((r) => `${r.title} (${r.url})`).join(', ') || (data.note || '')
|
(data.results || []).slice(0, 5).map((r) => `${r.title} (${r.url})`).join(', ') || (data.note || '')
|
||||||
}`;
|
}`;
|
||||||
case 'browse':
|
case 'browse':
|
||||||
return `Seite: ${data.title || data.url || ''} – ${String(data.text || '').slice(0, 500)}`;
|
return `Seite: ${data.title || data.url || ''} – ${String(data.text || '').slice(0, 500)}${
|
||||||
|
data.images?.length ? ` [${data.images.length} Bilder extrahiert]` : ''
|
||||||
|
}`;
|
||||||
case 'verify':
|
case 'verify':
|
||||||
return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`;
|
return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`;
|
||||||
case 'save_dashboard':
|
case 'save_dashboard':
|
||||||
return `Dashboard gespeichert: ${data.url || ''}`;
|
return data.note ? `Dashboard vorbereitet – HTML folgt in der finalen Antwort` : `Dashboard gespeichert: ${data.url || ''}`;
|
||||||
|
case 'geocode':
|
||||||
|
return data.error
|
||||||
|
? `Geocoding FEHLGESCHLAGEN: ${data.error}`
|
||||||
|
: `Geocodiert: ${data.name} -> ${data.lat}, ${data.lon}`;
|
||||||
|
case 'save_tour':
|
||||||
|
return data.error
|
||||||
|
? `Tour-Fehler: ${data.error}`
|
||||||
|
: `Tour gespeichert: ${data.title || ''} (${data.stops?.length || 0} Stopps, ${data.distanceKm} km, ${data.durationMin} min). GPX: ${data.gpxUrl || ''}`;
|
||||||
|
case 'screenshot':
|
||||||
|
return data.error
|
||||||
|
? `Screenshot FEHLGESCHLAGEN: ${data.error}`
|
||||||
|
: `Screenshot erstellt: ${data.title || data.url || ''} (Bild liegt im Kontext des Modells, nicht in der History)`;
|
||||||
default:
|
default:
|
||||||
return String(output).slice(0, 1000);
|
return String(output).slice(0, 1000);
|
||||||
}
|
}
|
||||||
@@ -125,32 +230,109 @@ function summarizeTool(toolName, output) {
|
|||||||
|
|
||||||
const MAX_ITERATIONS = 40;
|
const MAX_ITERATIONS = 40;
|
||||||
|
|
||||||
async function chatCompletion(config, messages) {
|
// Abbruch, wenn der API-Stream länger hängt (z.B. Upstream ohne Keep-Alive)
|
||||||
|
const REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
// Leere Modell-Antworten (kein Inhalt, keine Tool-Calls) einmal wiederholen
|
||||||
|
const MAX_EMPTY_RETRIES = 1;
|
||||||
|
|
||||||
|
/** Wird geworfen, wenn der Nutzer die Recherche manuell abbricht. */
|
||||||
|
class JobAbortedError extends Error {}
|
||||||
|
|
||||||
|
/** Baut den System-Prompt inkl. aktuellem Dashboard-Stand und Vision-Hinweisen. */
|
||||||
|
function buildSystemPrompt(session, model) {
|
||||||
|
let prompt = SYSTEM_PROMPT;
|
||||||
|
if (session.dashboard) {
|
||||||
|
const file = session.dashboard.split('/').pop().replace(/\.html$/, '');
|
||||||
|
prompt += `
|
||||||
|
|
||||||
|
## Aktueller Stand dieser Session
|
||||||
|
Für diese Session existiert bereits ein Dashboard: ${session.dashboard}
|
||||||
|
Wenn der Nutzer Änderungen, Ergänzungen oder Erweiterungen wünscht, MUSST du das save_dashboard-Tool mit EXAKT diesem Dateinamen aufrufen: "${file}" (ohne .html) – OHNE HTML im Aufruf. Das KOMPLETTE aktualisierte HTML sendest du danach in deiner finalen Antwort in einem \`\`\`html\`\`\`-Codeblock.
|
||||||
|
Eine bloße Erklärung oder Beschreibung der Änderungen im Chat ist KEINE Umsetzung – erst wenn der Codeblock gespeichert wurde, ist die Aufgabe erfüllt.`;
|
||||||
|
}
|
||||||
|
if (model.vision) {
|
||||||
|
prompt += `
|
||||||
|
|
||||||
|
## Bilderkennung & visuelle Produktsuche (Vision)
|
||||||
|
- Hat der Nutzer ein Bild hochgeladen (z.B. einen Schuh): Analysiere es SORGFÄLTIG – Gattung/Modelltyp (z.B. Sneaker, Loafer, Stiefelette), Farben (Haupt-, Neben- und Sohlenfarbe), Material (Leder, Mesh, Wildleder), Form/Details (Sohle, Schnürung, Logo, Muster), Marke und Zustand.
|
||||||
|
- Leite daraus 4-8 Suchbegriffe ab, Deutsch UND Englisch (z.B. "weißer minimalistischer leder-sneaker" / "white minimalist leather sneaker"), und suche gezielt nach ähnlichen Produkten und Angeboten. Priorität: Farbe, Form und Modelltyp.
|
||||||
|
- Das browse-Tool lädt Produktbilder der Seite automatisch lokal herunter und liefert im Feld \`images\` fertige Dashboard-URLs (z.B. /dashboards/session-img-abc123.jpg). Bette sie direkt ein: <img src="..." alt="..." loading="lazy">. Nur diese URLs verwenden – niemals erfinden.
|
||||||
|
- Baue eine Sektion "Visuelle Treffer" ins Dashboard: pro Treffer Produktbild, Name, ggf. Preis, verifizierter Link und eine kurze Begründung, warum er passt (Farbe/Form/Material).
|
||||||
|
- Nutze das screenshot-Tool, um bildlastige Seiten (Anzeigen-Listen, Produktseiten) visuell zu vergleichen, wenn der Text nicht ausreicht – achte auf Farbton, Silhouette und Sohlenform. Maximal ~5 Screenshots pro Session; priorisiere die aussichtsreichsten Treffer.
|
||||||
|
- Das hochgeladene Bild liegt dir als Data-URL im Verlauf vor – du kannst es als Referenz oben im Dashboard einbetten und beschreiben, worauf du beim Vergleich geachtet hast.`;
|
||||||
|
}
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erkennt Änderungswünsche am Dashboard in der Nutzernachricht. */
|
||||||
|
function isChangeRequest(text) {
|
||||||
|
return /dashboard|anpassen|anpass|ändern|änder|ergänz|erweiter|aktualisier|update|umbau|hinzufüg|hinzufueg|modifizier|vervollständig|vervollstaendig/i.test(
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML aus der finalen Antwort extrahieren (```html```-Codeblock oder direktes HTML). */
|
||||||
|
function extractHtmlBlock(content) {
|
||||||
|
const s = String(content || '');
|
||||||
|
const m = s.match(/```html\s*([\s\S]*?)```/i);
|
||||||
|
if (m && m[1].trim()) return m[1].trim();
|
||||||
|
const t = s.trim();
|
||||||
|
if (/^<!DOCTYPE html/i.test(t) || /^<html[\s>]/i.test(t)) return t;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codeblöcke aus der finalen Antwort entfernen (für die Chat-Anzeige). */
|
||||||
|
function stripHtmlBlock(content) {
|
||||||
|
return String(content || '').replace(/```html\s*[\s\S]*?```/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatCompletion(config, messages, systemPrompt, model, tools, signal, effortOverride) {
|
||||||
const body = {
|
const body = {
|
||||||
model: config.model,
|
model: model.id,
|
||||||
messages,
|
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
||||||
tools: TOOL_DEFINITIONS,
|
tools,
|
||||||
stream: true,
|
stream: true,
|
||||||
max_tokens: 32000,
|
max_tokens: model.maxTokens || config.maxTokens || 64000,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
};
|
};
|
||||||
if (config.thinking !== 'disabled') {
|
|
||||||
|
const baseUrl = config.baseUrl || 'https://api.deepseek.com';
|
||||||
|
|
||||||
|
if (effortOverride === 'none' || config.thinking === 'disabled') {
|
||||||
|
// DeepSeek: Denken pro Request abschaltbar (Liefer-Runde des Dashboards)
|
||||||
|
body.thinking = { type: 'disabled' };
|
||||||
|
} else {
|
||||||
body.thinking = { type: 'enabled' };
|
body.thinking = { type: 'enabled' };
|
||||||
body.reasoning_effort = 'medium';
|
body.reasoning_effort = effortOverride || model.reasoningEffort?.() || 'max';
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetch(`${config.baseUrl || 'https://api.deepseek.com'}/chat/completions`, {
|
const headers = {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
const fetchSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
signal: fetchSignal,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (signal?.aborted) throw new JobAbortedError();
|
||||||
|
throw new Error(`Modell-API nicht erreichbar (${model.id}): ${e.message.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.text().catch(() => '');
|
const err = await resp.text().catch(() => '');
|
||||||
throw new Error(`DeepSeek API Fehler ${resp.status}: ${err.slice(0, 500)}`);
|
throw new Error(`Modell-API Fehler ${resp.status} (${model.id}): ${err.slice(0, 500)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = resp.body.getReader();
|
const reader = resp.body.getReader();
|
||||||
@@ -173,6 +355,7 @@ async function chatCompletion(config, messages) {
|
|||||||
const choice = json.choices?.[0];
|
const choice = json.choices?.[0];
|
||||||
if (!choice) return;
|
if (!choice) return;
|
||||||
const d = choice.delta || {};
|
const d = choice.delta || {};
|
||||||
|
// DeepSeek liefert den Denk-Text als reasoning_content
|
||||||
if (d.reasoning_content) delta.reasoning += d.reasoning_content;
|
if (d.reasoning_content) delta.reasoning += d.reasoning_content;
|
||||||
if (d.content) delta.content += d.content;
|
if (d.content) delta.content += d.content;
|
||||||
if (d.tool_calls) {
|
if (d.tool_calls) {
|
||||||
@@ -191,6 +374,7 @@ async function chatCompletion(config, messages) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (choice.finish_reason) delta.finishReason = choice.finish_reason;
|
if (choice.finish_reason) delta.finishReason = choice.finish_reason;
|
||||||
|
else if (d.finish_reason) delta.finishReason = d.finish_reason;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -205,6 +389,36 @@ async function chatCompletion(config, messages) {
|
|||||||
}
|
}
|
||||||
if (buffer.trim()) feed(buffer);
|
if (buffer.trim()) feed(buffer);
|
||||||
|
|
||||||
|
// Stream ohne Abschlussstatus: unvollständig beendet (Upstream-Fehler)
|
||||||
|
if (!delta.finishReason && !delta.content && !delta.toolCalls) {
|
||||||
|
throw new Error(`Modell ${model.id}: Stream unvollständig beendet (keine Antwort erhalten). Bitte erneut versuchen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antwort abgeschnitten (max_tokens erreicht): Wenn weder Inhalt noch
|
||||||
|
// vollständige Tool-Calls vorhanden sind, endet die Recherche sonst
|
||||||
|
// stillschweigend ohne Ergebnis. Dann lieber sichtbar abbrechen.
|
||||||
|
if (delta.finishReason === 'length') {
|
||||||
|
const hasCalls = Array.isArray(delta.toolCalls) && delta.toolCalls.length > 0;
|
||||||
|
const callsComplete = !hasCalls || delta.toolCalls.every((tc) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(tc.function.arguments || '{}');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!hasCalls && !delta.content) {
|
||||||
|
throw new Error(
|
||||||
|
`Modell-Antwort abgeschnitten (Token-Limit bei ${model.id} erreicht) – Ergebnis unvollständig. Bitte erneut versuchen oder ein anderes Modell wählen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasCalls && !callsComplete) {
|
||||||
|
throw new Error(
|
||||||
|
`Tool-Aufruf abgeschnitten (Token-Limit bei ${model.id} erreicht) – Recherche wird abgebrochen. Bitte erneut versuchen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: delta.content,
|
content: delta.content,
|
||||||
reasoning: delta.reasoning,
|
reasoning: delta.reasoning,
|
||||||
@@ -222,9 +436,18 @@ async function chatCompletion(config, messages) {
|
|||||||
* @param {object} emit Event-Helfer: { token(t), thinking(t), progress(icon, text), step(msg), done(obj), error(msg) }
|
* @param {object} emit Event-Helfer: { token(t), thinking(t), progress(icon, text), step(msg), done(obj), error(msg) }
|
||||||
* @param {object} db Datenbankmodul
|
* @param {object} db Datenbankmodul
|
||||||
*/
|
*/
|
||||||
async function runResearchJob(config, session, userMessageText, emit, db) {
|
async function runResearchJob(config, session, userMessageText, emit, db, signal) {
|
||||||
let dashboardUrl = session.dashboard || null;
|
let dashboardUrl = session.dashboard || null;
|
||||||
let dashboardFile = dashboardUrl ? dashboardUrl.split('/').pop().replace('.html', '') : null;
|
let dashboardSaved = false;
|
||||||
|
let pendingDashboard = null;
|
||||||
|
// Sobald das Dashboard vorbereitet ist, läuft die nächste Runde mit minimalem
|
||||||
|
// Denken – das Budget gehört dann dem kompletten HTML (siehe deliveryEffort).
|
||||||
|
let deliveryEffort = null;
|
||||||
|
|
||||||
|
const model = getModel(session.model || config.model);
|
||||||
|
const toolDefs = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS;
|
||||||
|
const prices = model.prices;
|
||||||
|
const isAborted = () => signal?.aborted === true;
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
async search({ query }) {
|
async search({ query }) {
|
||||||
@@ -235,29 +458,58 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
|
|||||||
},
|
},
|
||||||
async browse({ url }) {
|
async browse({ url }) {
|
||||||
emit.progress('🌐', `Besuche: ${url}`);
|
emit.progress('🌐', `Besuche: ${url}`);
|
||||||
const result = await tools.browse(url, (m) => emit.step(m));
|
const result = await tools.browse(url, (m) => emit.step(m), { sessionId: session.id });
|
||||||
return JSON.stringify(result);
|
return JSON.stringify(result);
|
||||||
},
|
},
|
||||||
|
async screenshot({ url, full_page }) {
|
||||||
|
emit.progress('📷', `Screenshot: ${url}`);
|
||||||
|
return tools.screenshot(url, !!full_page, (m) => emit.step(m));
|
||||||
|
},
|
||||||
async verify({ url }) {
|
async verify({ url }) {
|
||||||
emit.progress('✅', `Verifiziere: ${url}`);
|
emit.progress('✅', `Verifiziere: ${url}`);
|
||||||
const result = await tools.verify(url, (m) => emit.step(m));
|
const result = await tools.verify(url, (m) => emit.step(m));
|
||||||
return JSON.stringify(result);
|
return JSON.stringify(result);
|
||||||
},
|
},
|
||||||
async save_dashboard({ title, filename, html }) {
|
async save_dashboard({ title, filename, html }) {
|
||||||
|
if (html && String(html).trim()) {
|
||||||
|
// Alter Weg: HTML direkt im Tool-Aufruf (nur kleine Dashboards)
|
||||||
emit.progress('📊', 'Dashboard wird gespeichert…');
|
emit.progress('📊', 'Dashboard wird gespeichert…');
|
||||||
const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m));
|
const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m));
|
||||||
dashboardUrl = saved.url;
|
dashboardUrl = saved.url;
|
||||||
dashboardFile = saved.file.replace('.html', '');
|
dashboardSaved = true;
|
||||||
db.setSessionDashboard(session.id, saved.url);
|
db.setSessionDashboard(session.id, saved.url);
|
||||||
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
|
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
|
||||||
return JSON.stringify(saved);
|
return JSON.stringify(saved);
|
||||||
|
}
|
||||||
|
// Neuer Weg: nur vorbereiten, HTML kommt in der finalen Antwort
|
||||||
|
pendingDashboard = { title: title || 'Dashboard', filename: filename || null };
|
||||||
|
deliveryEffort = model.deliveryEffort || 'none';
|
||||||
|
emit.progress(
|
||||||
|
'📊',
|
||||||
|
`Dashboard vorbereitet (${(filename || title || 'dashboard').toLowerCase().replace(/[^a-z0-9äöü-]+/g, '-')}.html) – sende jetzt das komplette HTML in deiner finalen Antwort als \`\`\`html\`\`\`-Codeblock…`
|
||||||
|
);
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
note: 'Dashboard vorbereitet. Sende jetzt das komplette HTML in deiner finalen Antwort in einem ```html```-Codeblock. Kein weiterer Tool-Aufruf nötig.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async geocode({ query }) {
|
||||||
|
emit.progress('📍', `Geocode: ${query}`);
|
||||||
|
const result = await tools.geocode(query, config, (m) => emit.step(m));
|
||||||
|
return JSON.stringify(result);
|
||||||
|
},
|
||||||
|
async save_tour({ title, filename, stops }) {
|
||||||
|
emit.progress('🗺️', `Tour wird erstellt: ${title || ''}`);
|
||||||
|
const result = await tools.saveTour({ stops, title, filename, sessionId: session.id }, config, (m) => emit.step(m));
|
||||||
|
return JSON.stringify(result);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiMessages = db.toApiMessages(session.id);
|
const apiMessages = db.toApiMessages(session.id, { provider: model.provider, vision: model.vision });
|
||||||
const text = userMessageText;
|
const text = userMessageText;
|
||||||
const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
|
const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
|
||||||
emit.progress('🤖', 'Research startet…');
|
let emptyRetries = 0;
|
||||||
|
emit.progress('🤖', `Research startet mit ${model.label}…`);
|
||||||
|
|
||||||
const trackUsage = (u) => {
|
const trackUsage = (u) => {
|
||||||
if (!u) return;
|
if (!u) return;
|
||||||
@@ -271,17 +523,33 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const finishJob = (finalText, dashboardUrl) => {
|
const finishJob = (finalText, dashboardUrl) => {
|
||||||
db.setSessionUsage(session.id, usage);
|
db.setSessionUsage(session.id, usage, prices);
|
||||||
db.setSessionStatus(session.id, 'done');
|
db.setSessionStatus(session.id, 'done');
|
||||||
if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl);
|
if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl);
|
||||||
const costEstimate = db.computeCost(usage);
|
const costEstimate = db.computeCost(usage, prices);
|
||||||
emit.done({ text: finalText, dashboardUrl, usage, costEstimate });
|
emit.done({ text: finalText, dashboardUrl, usage, costEstimate, model: model.id });
|
||||||
return { text: finalText, dashboardUrl, usage, costEstimate };
|
return { text: finalText, dashboardUrl, usage, costEstimate, model: model.id };
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishAborted = () => {
|
||||||
|
db.createMessage(session.id, { role: 'assistant', content: '⛔ Recherche abgebrochen.' });
|
||||||
|
db.setSessionUsage(session.id, usage, prices);
|
||||||
|
db.setSessionStatus(session.id, 'done');
|
||||||
|
const costEstimate = db.computeCost(usage, prices);
|
||||||
|
emit.aborted?.();
|
||||||
|
return { text: '⛔ Recherche abgebrochen.', dashboardUrl, usage, costEstimate, model: model.id, aborted: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
|
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
|
||||||
|
if (isAborted()) return finishAborted();
|
||||||
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…');
|
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…');
|
||||||
const result = await chatCompletion(config, apiMessages);
|
let result;
|
||||||
|
try {
|
||||||
|
result = await chatCompletion(config, apiMessages, buildSystemPrompt(session, model), model, toolDefs, signal, deliveryEffort);
|
||||||
|
} catch (e) {
|
||||||
|
if (isAborted() || e instanceof JobAbortedError) return finishAborted();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
trackUsage(result.usage);
|
trackUsage(result.usage);
|
||||||
|
|
||||||
if (result.reasoning) emit.thinking(result.reasoning);
|
if (result.reasoning) emit.thinking(result.reasoning);
|
||||||
@@ -296,7 +564,14 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
|
|||||||
function: { name: tc.function.name, arguments: tc.function.arguments },
|
function: { name: tc.function.name, arguments: tc.function.arguments },
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
db.createMessage(session.id, { role: 'assistant', content: assistantMsg.content, toolCalls: assistantMsg.tool_calls });
|
// DeepSeek: Reasoning-Text muss bei tools-Requests mit zurückgegeben werden
|
||||||
|
if (model.provider === 'deepseek' && result.reasoning) assistantMsg.reasoning_content = result.reasoning;
|
||||||
|
db.createMessage(session.id, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantMsg.content,
|
||||||
|
toolCalls: assistantMsg.tool_calls,
|
||||||
|
reasoning: result.reasoning || null,
|
||||||
|
});
|
||||||
apiMessages.push(assistantMsg);
|
apiMessages.push(assistantMsg);
|
||||||
|
|
||||||
for (const tc of result.toolCalls) {
|
for (const tc of result.toolCalls) {
|
||||||
@@ -312,19 +587,91 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
|
|||||||
output = JSON.stringify({ error: `Tool-Fehler: ${e.message.slice(0, 300)}` });
|
output = JSON.stringify({ error: `Tool-Fehler: ${e.message.slice(0, 300)}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const outStr = typeof output === 'string' ? output : JSON.stringify(output);
|
||||||
db.createMessage(session.id, {
|
db.createMessage(session.id, {
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
toolName: tc.function.name,
|
toolName: tc.function.name,
|
||||||
toolCallId: tc.id,
|
toolCallId: tc.id,
|
||||||
content: summarizeTool(tc.function.name, output).slice(0, 8000),
|
content: summarizeTool(tc.function.name, outStr).slice(0, 8000),
|
||||||
});
|
});
|
||||||
apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: output.slice(0, 8000) });
|
if (output && typeof output === 'object' && output.image) {
|
||||||
|
// Screenshot: Bild nur im laufenden Verlauf, DB bekommt die Zusammenfassung
|
||||||
|
apiMessages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: (output.text || output.title || '').slice(0, 2000) },
|
||||||
|
{ type: 'image_url', image_url: { url: output.image } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: outStr.slice(0, 8000) });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (isAborted()) return finishAborted();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : '');
|
// Leere Antwort: Modell-Glitch oder abgebrochener Stream
|
||||||
db.createMessage(session.id, { role: 'assistant', content: finalText });
|
if (!result.content) {
|
||||||
|
if (!pendingDashboard && !dashboardUrl && emptyRetries < MAX_EMPTY_RETRIES) {
|
||||||
|
emptyRetries++;
|
||||||
|
emit.progress('🔄', 'Leere Antwort erhalten – versuche es erneut…');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pendingDashboard) {
|
||||||
|
throw new Error(
|
||||||
|
`Modell ${model.id} hat das Dashboard-HTML nicht geliefert (leere Antwort). Bitte erneut versuchen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!dashboardUrl) {
|
||||||
|
const hint = result.reasoning
|
||||||
|
? ` Letzter Gedanke: „${String(result.reasoning).slice(-200)}“`
|
||||||
|
: '';
|
||||||
|
throw new Error(
|
||||||
|
`Modell ${model.id} hat keine Antwort geliefert.${hint} Bitte erneut versuchen oder ein anderes Modell wählen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard vorbereitet → HTML aus der finalen Antwort extrahieren und speichern
|
||||||
|
if (pendingDashboard && result.content) {
|
||||||
|
const html = extractHtmlBlock(result.content);
|
||||||
|
if (!html) {
|
||||||
|
if (/```html/i.test(result.content)) {
|
||||||
|
throw new Error(
|
||||||
|
`Modell ${model.id}: Dashboard-HTML unvollständig (Codeblock nicht geschlossen) – möglicherweise wurde das Token-Limit erreicht. Bitte erneut versuchen oder ein kleineres Dashboard anfragen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Modell ${model.id} hat das Dashboard-HTML nicht geliefert (kein \`\`\`html\`\`\`-Codeblock in der Antwort). Bitte erneut versuchen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const saved = tools.saveDashboard(
|
||||||
|
session.id,
|
||||||
|
pendingDashboard.filename || pendingDashboard.title,
|
||||||
|
html,
|
||||||
|
(m) => emit.step(m)
|
||||||
|
);
|
||||||
|
dashboardUrl = saved.url;
|
||||||
|
dashboardSaved = true;
|
||||||
|
db.setSessionDashboard(session.id, saved.url);
|
||||||
|
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
|
||||||
|
result.content = stripHtmlBlock(result.content).trim() || `Fertig! Dein Dashboard ist online: ${saved.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : 'Recherche abgeschlossen.');
|
||||||
|
if (!dashboardSaved && dashboardUrl && isChangeRequest(userMessageText)) {
|
||||||
|
emit.progress(
|
||||||
|
'⚠️',
|
||||||
|
'Achtung: Das Dashboard wurde nicht aktualisiert – der Agent hat save_dashboard nicht aufgerufen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
db.createMessage(session.id, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: finalText,
|
||||||
|
reasoning: result.reasoning || null,
|
||||||
|
});
|
||||||
return finishJob(finalText, dashboardUrl);
|
return finishJob(finalText, dashboardUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,10 +679,10 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: 'Maximale Iterationen erreicht – Recherche abgebrochen. Bitte neue Session starten.',
|
content: 'Maximale Iterationen erreicht – Recherche abgebrochen. Bitte neue Session starten.',
|
||||||
});
|
});
|
||||||
db.setSessionUsage(session.id, usage);
|
db.setSessionUsage(session.id, usage, prices);
|
||||||
db.setSessionStatus(session.id, 'done');
|
db.setSessionStatus(session.id, 'done');
|
||||||
emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.');
|
emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.');
|
||||||
return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage) };
|
return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage, prices), model: model.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };
|
module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };
|
||||||
+317
-6
@@ -1,5 +1,6 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { chromium } = require('playwright');
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
const DASHBOARD_DIR = path.join(__dirname, '..', 'dashboards');
|
const DASHBOARD_DIR = path.join(__dirname, '..', 'dashboards');
|
||||||
@@ -92,8 +93,76 @@ async function search(query, log) {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seite besuchen und lesbaren Text extrahieren. */
|
const IMAGE_TYPES = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif' };
|
||||||
async function browse(url, log) {
|
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||||
|
const MAX_IMAGES = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert Produktbild-URLs (nur absolute http(s), ausreichend groß, dedupliziert).
|
||||||
|
* Läuft im Browser-Kontext der besuchten Seite.
|
||||||
|
*/
|
||||||
|
function collectImages() {
|
||||||
|
const images = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const img of document.querySelectorAll('img')) {
|
||||||
|
const src = (img.currentSrc || img.src || '').split('#')[0];
|
||||||
|
if (!/^https?:/i.test(src) || seen.has(src)) continue;
|
||||||
|
const nw = img.naturalWidth || 0;
|
||||||
|
const nh = img.naturalHeight || 0;
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
const w = nw || rect.width || img.width || 0;
|
||||||
|
const h = nh || rect.height || img.height || 0;
|
||||||
|
if (w < 120 || h < 120) continue;
|
||||||
|
seen.add(src);
|
||||||
|
images.push({ url: src, alt: (img.alt || '').trim().slice(0, 120) });
|
||||||
|
if (images.length >= 12) break;
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt extrahierte Bilder lokal in den Dashboards-Ordner, damit das Dashboard
|
||||||
|
* sie unabhängig von Hotlink-Schutz und ablaufenden CDN-URLs einbetten kann.
|
||||||
|
* Dateiname: <sessionId>-img-<hash>.<ext> – wird beim Session-Löschen mit entfernt.
|
||||||
|
*/
|
||||||
|
async function cacheImages(page, images, sessionId, log) {
|
||||||
|
if (!sessionId) return images;
|
||||||
|
fs.mkdirSync(DASHBOARD_DIR, { recursive: true });
|
||||||
|
const existingFiles = new Set(fs.readdirSync(DASHBOARD_DIR));
|
||||||
|
const out = [];
|
||||||
|
let total = 0;
|
||||||
|
for (const img of images) {
|
||||||
|
if (out.length >= MAX_IMAGES || total >= MAX_TOTAL_IMAGE_BYTES) break;
|
||||||
|
try {
|
||||||
|
const hash = crypto.createHash('sha1').update(img.url).digest('hex').slice(0, 12);
|
||||||
|
const known = [...existingFiles].find((f) => f.startsWith(`${sessionId}-img-${hash}.`));
|
||||||
|
if (known) {
|
||||||
|
out.push({ url: `/dashboards/${known}`, alt: img.alt });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resp = await page.request.get(img.url, {
|
||||||
|
timeout: 15000,
|
||||||
|
headers: { Referer: page.url() },
|
||||||
|
});
|
||||||
|
const type = String(resp.headers()['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||||
|
const ext = IMAGE_TYPES[type];
|
||||||
|
if (!ext || !resp.ok()) continue;
|
||||||
|
const buf = await resp.body();
|
||||||
|
if (buf.length < 512 || buf.length > MAX_IMAGE_BYTES || total + buf.length > MAX_TOTAL_IMAGE_BYTES) continue;
|
||||||
|
const file = `${sessionId}-img-${hash}.${ext}`;
|
||||||
|
fs.writeFileSync(path.join(DASHBOARD_DIR, file), buf);
|
||||||
|
existingFiles.add(file);
|
||||||
|
total += buf.length;
|
||||||
|
out.push({ url: `/dashboards/${file}`, alt: img.alt });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
log?.(`Produktbilder lokal gespeichert: ${out.length}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seite besuchen und lesbaren Text + Produktbilder (lokal gespeichert) extrahieren. */
|
||||||
|
async function browse(url, log, opts = {}) {
|
||||||
return withPage(url, async (page) => {
|
return withPage(url, async (page) => {
|
||||||
const nav = await goto(page, url);
|
const nav = await goto(page, url);
|
||||||
if (!nav.ok) return { nav, error: 'Seite konnte nicht geladen werden' };
|
if (!nav.ok) return { nav, error: 'Seite konnte nicht geladen werden' };
|
||||||
@@ -101,10 +170,12 @@ async function browse(url, log) {
|
|||||||
const data = await page.evaluate(() => {
|
const data = await page.evaluate(() => {
|
||||||
const title = document.title || '';
|
const title = document.title || '';
|
||||||
const text = (document.body ? document.body.innerText : '').replace(/\s+\n/g, '\n');
|
const text = (document.body ? document.body.innerText : '').replace(/\s+\n/g, '\n');
|
||||||
return { title, text: text.slice(0, 15000) };
|
return { title, text: text.slice(0, 6000) };
|
||||||
});
|
});
|
||||||
log?.(`Besuche ${url} -> ${data.title}`);
|
const extracted = await page.evaluate(collectImages);
|
||||||
return { nav, title: data.title, text: data.text };
|
const images = await cacheImages(page, extracted, opts.sessionId, log);
|
||||||
|
log?.(`Besuche ${url} -> ${data.title} (${images.length} Bilder lokal)`);
|
||||||
|
return { nav, title: data.title, images, text: data.text };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +215,246 @@ function saveDashboard(sessionId, filename, html, log) {
|
|||||||
return { file, url: `/dashboards/${file}` };
|
return { file, url: `/dashboards/${file}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alle Dashboard-Dateien einer Session (HTML/GPX/tour.json) löschen. */
|
||||||
|
function deleteSessionFiles(sessionId) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(DASHBOARD_DIR);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const f of entries) {
|
||||||
|
if (f.startsWith(`${sessionId}-`)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(path.join(DASHBOARD_DIR, f));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screenshot einer Seite (JPEG, als Data-URL) – für Vision-Modelle. */
|
||||||
|
async function screenshot(url, fullPage = false, log) {
|
||||||
|
return withPage(url, async (page) => {
|
||||||
|
const nav = await goto(page, url);
|
||||||
|
if (!nav.ok) return { error: `Seite konnte nicht geladen werden (${nav.status || 'Timeout'})` };
|
||||||
|
const title = await page.title().catch(() => '');
|
||||||
|
let useFull = !!fullPage;
|
||||||
|
if (useFull) {
|
||||||
|
const h = await page.evaluate(() => document.body?.scrollHeight || 0).catch(() => 0);
|
||||||
|
if (h > 6000) useFull = false;
|
||||||
|
}
|
||||||
|
const buf = await page.screenshot({ fullPage: useFull, type: 'jpeg', quality: 70 });
|
||||||
|
log?.(`Screenshot: ${url}${useFull ? ' (voll)' : ''} – ${title || ''}`);
|
||||||
|
return {
|
||||||
|
title: title || nav.finalUrl || url,
|
||||||
|
url: nav.finalUrl || url,
|
||||||
|
image: `data:image/jpeg;base64,${buf.toString('base64')}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tour-Feature: Geocoding (Nominatim) + Routing (OSRM) + GPX-Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NOMINATIM_UA = 'ResearchChat/1.0 (Kontakt: research@localhost)';
|
||||||
|
let lastGeocodeTime = 0;
|
||||||
|
|
||||||
|
/** Koordinaten-Paare in OSRM-Format: lon,lat (ACHTUNG Reihenfolge). */
|
||||||
|
const toOsrm = (stops) => stops.map((s) => `${s.lon},${s.lat}`).join(';');
|
||||||
|
|
||||||
|
/** GPX-/XML-sichere Zeichen escapen. */
|
||||||
|
function xmlEscape(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Koordinaten-Angabe erkennen: "52.5163, 13.3777" oder "52.5163 13.3777". */
|
||||||
|
function parseCoords(query) {
|
||||||
|
const m = String(query).match(/(-?\d{1,3}(?:\.\d+)?)\s*[,;\s]\s*(-?\d{1,3}(?:\.\d+)?)/);
|
||||||
|
if (!m) return null;
|
||||||
|
const lat = parseFloat(m[1]);
|
||||||
|
const lon = parseFloat(m[2]);
|
||||||
|
if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat, lon };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ort über Nominatim (OpenStreetMap) in Koordinaten umwandeln.
|
||||||
|
* Kostenlos, Fair-Use: max. 1 Request/Sekunde.
|
||||||
|
*/
|
||||||
|
async function geocode(query, config, log) {
|
||||||
|
const coords = parseCoords(query);
|
||||||
|
if (coords) {
|
||||||
|
log?.(`Geocode "${query}" -> Koordinaten direkt übernommen`);
|
||||||
|
return { ...coords, name: query, displayName: query, direct: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = Math.max(0, lastGeocodeTime + 1100 - Date.now());
|
||||||
|
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||||
|
lastGeocodeTime = Date.now();
|
||||||
|
|
||||||
|
const url = new URL('https://nominatim.openstreetmap.org/search');
|
||||||
|
url.searchParams.set('q', query);
|
||||||
|
url.searchParams.set('format', 'json');
|
||||||
|
url.searchParams.set('limit', '1');
|
||||||
|
url.searchParams.set('accept-language', 'de');
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': config?.nominatimEmail ? `ResearchChat/1.0 (${config.nominatimEmail})` : NOMINATIM_UA,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { headers, timeout: 15000 });
|
||||||
|
if (!resp.ok) return { error: `Nominatim Fehler ${resp.status}` };
|
||||||
|
const results = await resp.json();
|
||||||
|
if (!results.length) return { error: `Kein Ort gefunden: "${query}"` };
|
||||||
|
const r = results[0];
|
||||||
|
log?.(`Geocode "${query}" -> ${r.lat}, ${r.lon}`);
|
||||||
|
return {
|
||||||
|
name: r.name || query,
|
||||||
|
lat: parseFloat(r.lat),
|
||||||
|
lon: parseFloat(r.lon),
|
||||||
|
displayName: r.display_name,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { error: `Nominatim nicht erreichbar: ${e.message.slice(0, 120)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geometrie entschlacken (max. 500 Punkte für das Dashboard). */
|
||||||
|
function decimate(coords, max = 500) {
|
||||||
|
if (coords.length <= max) return coords;
|
||||||
|
const step = coords.length / max;
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < coords.length; i += step) out.push(coords[Math.floor(i)]);
|
||||||
|
out.push(coords[coords.length - 1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tour speichern: OSRM-Fuß-Route durch alle Stopps, GPX-Datei für OsmAnd,
|
||||||
|
* tour.json für die Dashboard-Karte, Google-Maps-URLs.
|
||||||
|
*/
|
||||||
|
async function saveTour({ stops, title, filename, sessionId }, config, log) {
|
||||||
|
if (!Array.isArray(stops) || stops.length < 2) {
|
||||||
|
return { error: 'Mindestens 2 Stopps nötig (Name, lat, lon)' };
|
||||||
|
}
|
||||||
|
const clean = stops
|
||||||
|
.filter((s) => s && s.name && Number.isFinite(Number(s.lat)) && Number.isFinite(Number(s.lon)))
|
||||||
|
.map((s) => ({
|
||||||
|
name: String(s.name).slice(0, 100),
|
||||||
|
lat: Number(s.lat),
|
||||||
|
lon: Number(s.lon),
|
||||||
|
description: String(s.description || '').slice(0, 300),
|
||||||
|
}));
|
||||||
|
if (clean.length < 2) return { error: 'Stopps ungültig (Name + lat + lon erforderlich)' };
|
||||||
|
|
||||||
|
// OSRM-Fuß-Route (Fallback: Luftlinie)
|
||||||
|
let route = { coordinates: clean.map((s) => [s.lon, s.lat]), distanceKm: 0, durationMin: 0, source: 'line' };
|
||||||
|
if (clean.length <= 25) {
|
||||||
|
try {
|
||||||
|
const osrmUrl = `${config?.osrmUrl || 'https://router.project-osrm.org'}/route/v1/foot/${toOsrm(clean)}?overview=full&geometries=geojson`;
|
||||||
|
const resp = await fetch(osrmUrl, { timeout: 20000 });
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
const r = data.routes?.[0];
|
||||||
|
if (r?.geometry?.coordinates?.length) {
|
||||||
|
route = {
|
||||||
|
coordinates: decimate(r.geometry.coordinates), // für die Karte im Dashboard
|
||||||
|
fullCoordinates: r.geometry.coordinates, // für die GPX-Datei (OsmAnd)
|
||||||
|
distanceKm: Math.round((r.distance / 1000) * 10) / 10,
|
||||||
|
durationMin: Math.round(r.duration / 60),
|
||||||
|
source: 'osrm',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log?.(`OSRM antwortet ${resp.status} – Fallback: Luftlinie`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log?.(`OSRM nicht erreichbar – Fallback: Luftlinie (${e.message.slice(0, 80)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPX 1.1 bauen
|
||||||
|
const wpts = clean
|
||||||
|
.map(
|
||||||
|
(s, i) =>
|
||||||
|
` <wpt lat="${s.lat.toFixed(6)}" lon="${s.lon.toFixed(6)}">\n` +
|
||||||
|
` <name>${xmlEscape(i + 1)}. ${xmlEscape(s.name)}</name>\n` +
|
||||||
|
(s.description ? ` <desc>${xmlEscape(s.description)}</desc>\n` : '') +
|
||||||
|
` </wpt>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
const rtepts = clean
|
||||||
|
.map((s) => ` <rtept lat="${s.lat.toFixed(6)}" lon="${s.lon.toFixed(6)}"><name>${xmlEscape(s.name)}</name></rtept>`)
|
||||||
|
.join('\n');
|
||||||
|
const trkpts = (route.fullCoordinates || route.coordinates)
|
||||||
|
.map(([lon, lat]) => ` <trkpt lat="${lat.toFixed(6)}" lon="${lon.toFixed(6)}"></trkpt>`)
|
||||||
|
.join('\n');
|
||||||
|
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="ResearchChat" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<metadata>
|
||||||
|
<name>${xmlEscape(title || 'Tour')}</name>
|
||||||
|
</metadata>
|
||||||
|
${wpts}
|
||||||
|
<rte>
|
||||||
|
<name>${xmlEscape(title || 'Tour')}</name>
|
||||||
|
${rtepts}
|
||||||
|
</rte>
|
||||||
|
<trk>
|
||||||
|
<name>${xmlEscape(title || 'Tour')} (Route)</name>
|
||||||
|
<trkseg>
|
||||||
|
${trkpts}
|
||||||
|
</trkseg>
|
||||||
|
</trk>
|
||||||
|
</gpx>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const safeName =
|
||||||
|
(filename || title || 'tour').toLowerCase().replace(/[^a-z0-9äöü-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) ||
|
||||||
|
'tour';
|
||||||
|
const base = `${sessionId}-${safeName.replace(/-tour$/, '')}-tour`;
|
||||||
|
fs.mkdirSync(DASHBOARD_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(DASHBOARD_DIR, `${base}.gpx`), gpx, 'utf8');
|
||||||
|
const { fullCoordinates, ...routeForJson } = route;
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(DASHBOARD_DIR, `${base}.tour.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
title: title || 'Tour',
|
||||||
|
stops: clean,
|
||||||
|
route: routeForJson,
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const fmt = (lat, lon) => `${lat.toFixed(6)},${lon.toFixed(6)}`;
|
||||||
|
const googleMapsUrl = `https://www.google.com/maps/dir/${clean.map((s) => fmt(s.lat, s.lon)).join('/')}/`;
|
||||||
|
const stopsWithNav = clean.map((s) => ({
|
||||||
|
...s,
|
||||||
|
navUrl: `https://www.google.com/maps/dir/?api=1&destination=${fmt(s.lat, s.lon)}&travelmode=walking`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
log?.(
|
||||||
|
`Tour gespeichert: ${base} (${clean.length} Stopps, ${route.distanceKm} km, ${route.durationMin} min, ${route.source})`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
title: title || 'Tour',
|
||||||
|
gpxUrl: `/dashboards/${base}.gpx`,
|
||||||
|
tourJsonUrl: `/dashboards/${base}.tour.json`,
|
||||||
|
googleMapsUrl,
|
||||||
|
stops: stopsWithNav,
|
||||||
|
distanceKm: route.distanceKm,
|
||||||
|
durationMin: route.durationMin,
|
||||||
|
routeSource: route.source,
|
||||||
|
osmAndHint: 'GPX-Datei herunterladen und in OsmAnd importieren (Menü → Meine Orte → Importieren).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function closeBrowser() {
|
async function closeBrowser() {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close().catch(() => {});
|
await browser.close().catch(() => {});
|
||||||
@@ -151,4 +462,4 @@ async function closeBrowser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { search, browse, verify, saveDashboard, closeBrowser };
|
module.exports = { search, browse, verify, saveDashboard, screenshot, geocode, saveTour, deleteSessionFiles, closeBrowser };
|
||||||
+200
-11
@@ -5,6 +5,9 @@ const state = {
|
|||||||
stream: null,
|
stream: null,
|
||||||
streamingEl: null,
|
streamingEl: null,
|
||||||
activityShown: false,
|
activityShown: false,
|
||||||
|
models: [],
|
||||||
|
modelById: {},
|
||||||
|
pendingImage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -32,7 +35,7 @@ function mdLite(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toolLabel(msg) {
|
function toolLabel(msg) {
|
||||||
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' };
|
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊', screenshot: '📷' };
|
||||||
const icon = icons[msg.tool_name] || '🔧';
|
const icon = icons[msg.tool_name] || '🔧';
|
||||||
const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content;
|
const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content;
|
||||||
return { icon, text: text?.slice(0, 200) };
|
return { icon, text: text?.slice(0, 200) };
|
||||||
@@ -59,27 +62,63 @@ function renderSessions() {
|
|||||||
: s.status === 'done'
|
: s.status === 'done'
|
||||||
? '<span class="w-2 h-2 rounded-full bg-emerald-400 inline-block mr-2"></span>'
|
? '<span class="w-2 h-2 rounded-full bg-emerald-400 inline-block mr-2"></span>'
|
||||||
: '<span class="w-2 h-2 rounded-full bg-slate-500 inline-block mr-2"></span>';
|
: '<span class="w-2 h-2 rounded-full bg-slate-500 inline-block mr-2"></span>';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'group relative';
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-start gap-1 ${
|
btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-start gap-1 ${
|
||||||
active ? 'bg-blue-600/20 text-blue-300' : 'hover:bg-slate-800'
|
active ? 'bg-blue-600/20 text-blue-300' : 'hover:bg-slate-800'
|
||||||
}`;
|
}`;
|
||||||
btn.innerHTML = `${dot}<span class="truncate">${escapeHtml(s.title)}</span>`;
|
btn.innerHTML = `${dot}<span class="truncate flex-1">${escapeHtml(s.title)}</span>`;
|
||||||
btn.onclick = () => openSession(s.id);
|
btn.onclick = () => openSession(s.id);
|
||||||
list.appendChild(btn);
|
const del = document.createElement('button');
|
||||||
|
del.className =
|
||||||
|
'absolute right-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded text-xs text-slate-500 hover:text-red-400 hover:bg-slate-700 opacity-0 group-hover:opacity-100';
|
||||||
|
del.textContent = '🗑';
|
||||||
|
del.title = 'Chat löschen';
|
||||||
|
del.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteSession(s.id);
|
||||||
|
};
|
||||||
|
row.append(btn, del);
|
||||||
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued');
|
const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued');
|
||||||
$('queueStatus').classList.toggle('hidden', !qs);
|
$('queueStatus').classList.toggle('hidden', !qs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteSession(id) {
|
||||||
|
const s = state.sessions.find((x) => x.id === id);
|
||||||
|
if (!confirm(`Chat „${s?.title || '…'}“ wirklich löschen? Das zugehörige Dashboard wird mit gelöscht.`)) return;
|
||||||
|
const res = await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
addToolLine('⚠️', 'Löschen fehlgeschlagen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.current?.id === id) {
|
||||||
|
state.stream?.close();
|
||||||
|
state.stream = null;
|
||||||
|
state.streamingEl = null;
|
||||||
|
state.current = null;
|
||||||
|
hideActivity();
|
||||||
|
const next = state.sessions.find((x) => x.id !== id);
|
||||||
|
if (next) await openSession(next.id);
|
||||||
|
else await newSession();
|
||||||
|
} else {
|
||||||
|
await loadSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Chat-Rendering
|
// Chat-Rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function addUserMessage(text) {
|
function addUserMessage(text, image) {
|
||||||
$('emptyState')?.remove();
|
$('emptyState')?.remove();
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'flex justify-end';
|
el.className = 'flex justify-end';
|
||||||
el.innerHTML = `<div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${mdLite(text)}</div>`;
|
el.innerHTML = `<div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${
|
||||||
|
image ? `<img src="${image}" alt="Angehängtes Bild" class="rounded-lg mb-2 max-h-48">` : ''
|
||||||
|
}${mdLite(text)}</div>`;
|
||||||
$('chat').appendChild(el);
|
$('chat').appendChild(el);
|
||||||
scrollDown();
|
scrollDown();
|
||||||
}
|
}
|
||||||
@@ -154,7 +193,7 @@ function renderMessages(messages, session) {
|
|||||||
}
|
}
|
||||||
addDashboardCard(session);
|
addDashboardCard(session);
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (m.role === 'user') addUserMessage(m.content);
|
if (m.role === 'user') addUserMessage(m.content, m.image || null);
|
||||||
else if (m.role === 'assistant') {
|
else if (m.role === 'assistant') {
|
||||||
if (m.content) addAssistantBubble(m.content);
|
if (m.content) addAssistantBubble(m.content);
|
||||||
} else if (m.role === 'tool') {
|
} else if (m.role === 'tool') {
|
||||||
@@ -182,6 +221,8 @@ function showActivity() {
|
|||||||
$('activityLog').classList.add('hidden');
|
$('activityLog').classList.add('hidden');
|
||||||
$('toggleLog').textContent = 'Details zeigen';
|
$('toggleLog').textContent = 'Details zeigen';
|
||||||
state.activityShown = true;
|
state.activityShown = true;
|
||||||
|
$('abortBtn').classList.remove('hidden');
|
||||||
|
$('abortBtn').disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActivity(text, icon = '') {
|
function setActivity(text, icon = '') {
|
||||||
@@ -197,15 +238,29 @@ function logActivity(text) {
|
|||||||
|
|
||||||
function hideActivity() {
|
function hideActivity() {
|
||||||
$('activityBar').classList.add('hidden');
|
$('activityBar').classList.add('hidden');
|
||||||
|
$('abortBtn').classList.add('hidden');
|
||||||
state.activityShown = false;
|
state.activityShown = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function abortResearch() {
|
||||||
|
if (!state.current) return;
|
||||||
|
$('abortBtn').disabled = true;
|
||||||
|
setActivity('Breche Recherche ab…', '⛔');
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sessions/${state.current.id}/abort`, { method: 'POST' });
|
||||||
|
} catch {
|
||||||
|
$('abortBtn').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$('toggleLog').onclick = () => {
|
$('toggleLog').onclick = () => {
|
||||||
const log = $('activityLog');
|
const log = $('activityLog');
|
||||||
const hidden = log.classList.toggle('hidden');
|
const hidden = log.classList.toggle('hidden');
|
||||||
$('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen';
|
$('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$('abortBtn').onclick = abortResearch;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SSE-Stream
|
// SSE-Stream
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -281,6 +336,13 @@ function connectStream(sessionId) {
|
|||||||
$('sendBtn').disabled = false;
|
$('sendBtn').disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
es.addEventListener('aborted', () => {
|
||||||
|
addToolLine('⛔', 'Recherche abgebrochen.');
|
||||||
|
hideActivity();
|
||||||
|
loadSessions();
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
es.addEventListener('error', (e) => {
|
es.addEventListener('error', (e) => {
|
||||||
if (!e.data) return;
|
if (!e.data) return;
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
@@ -348,11 +410,15 @@ async function openSession(id) {
|
|||||||
state.stream?.close();
|
state.stream?.close();
|
||||||
state.streamingEl = null;
|
state.streamingEl = null;
|
||||||
hideActivity();
|
hideActivity();
|
||||||
|
clearImage();
|
||||||
const res = await fetch(`/api/sessions/${id}`);
|
const res = await fetch(`/api/sessions/${id}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
state.current = data.session;
|
state.current = data.session;
|
||||||
|
if (!state.current.model) state.current.model = state.models[0]?.id || null;
|
||||||
renderMessages(data.messages, data.session);
|
renderMessages(data.messages, data.session);
|
||||||
$('mobileTitle').textContent = data.session.title;
|
$('mobileTitle').textContent = data.session.title;
|
||||||
|
setSelectModel(state.current.model);
|
||||||
|
updateModelUI();
|
||||||
renderSessions();
|
renderSessions();
|
||||||
const q = state.sessions.find((s) => s.id === id);
|
const q = state.sessions.find((s) => s.id === id);
|
||||||
if (data.session.status === 'running' || q?.job === 'running' || q?.job === 'queued') {
|
if (data.session.status === 'running' || q?.job === 'running' || q?.job === 'queued') {
|
||||||
@@ -370,6 +436,114 @@ async function newSession() {
|
|||||||
$('input').focus();
|
$('input').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modell-Auswahl
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
const res = await fetch('/api/models');
|
||||||
|
const data = await res.json();
|
||||||
|
state.models = data.models || [];
|
||||||
|
state.modelById = Object.fromEntries(state.models.map((m) => [m.id, m]));
|
||||||
|
const opts = state.models.map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.label)}</option>`).join('');
|
||||||
|
$('modelSelectDesktop').innerHTML = opts;
|
||||||
|
$('modelSelectMobile').innerHTML = opts;
|
||||||
|
return data.default || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentModel() {
|
||||||
|
return state.modelById[state.current?.model] || state.models[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectModel(id) {
|
||||||
|
$('modelSelectDesktop').value = id || '';
|
||||||
|
$('modelSelectMobile').value = id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeModel(id) {
|
||||||
|
if (!state.current || state.current.model === id) return;
|
||||||
|
const res = await fetch(`/api/sessions/${state.current.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: id }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
addToolLine('⚠️', d.error || 'Modellwechsel fehlgeschlagen');
|
||||||
|
setSelectModel(state.current.model);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.current.model = id;
|
||||||
|
const s = state.sessions.find((x) => x.id === state.current.id);
|
||||||
|
if (s) s.model = id;
|
||||||
|
updateModelUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModelUI() {
|
||||||
|
const m = currentModel();
|
||||||
|
const vision = !!m?.vision;
|
||||||
|
const btn = $('imageBtn');
|
||||||
|
btn.disabled = !vision;
|
||||||
|
btn.classList.toggle('opacity-40', !vision);
|
||||||
|
const visionLabel = state.models.find((x) => x.vision)?.label || 'einem Vision-Modell';
|
||||||
|
btn.title = vision ? 'Bild anhängen' : `Bilderkennung nur mit Vision-Modell möglich (z.B. ${visionLabel})`;
|
||||||
|
if (!vision && state.pendingImage) clearImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bild-Upload (Vision)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fileToDataUrl(file, maxDim = 1280, quality = 0.82) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(img.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(img.height * scale));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', quality));
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden'));
|
||||||
|
img.src = reader.result;
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showImagePreview(dataUrl) {
|
||||||
|
state.pendingImage = dataUrl;
|
||||||
|
$('imagePreviewImg').src = dataUrl;
|
||||||
|
$('imagePreview').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
state.pendingImage = null;
|
||||||
|
$('imageInput').value = '';
|
||||||
|
$('imagePreview').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const dataUrl = await fileToDataUrl(file);
|
||||||
|
if (dataUrl.length > 3500000) {
|
||||||
|
addToolLine('⚠️', 'Bild zu groß – bitte ein kleineres Foto wählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showImagePreview(dataUrl);
|
||||||
|
} catch (e) {
|
||||||
|
addToolLine('⚠️', e.message || 'Bild konnte nicht verarbeitet werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyText(text, btn, label) {
|
function copyText(text, btn, label) {
|
||||||
try {
|
try {
|
||||||
navigator.clipboard.writeText(text).then(
|
navigator.clipboard.writeText(text).then(
|
||||||
@@ -424,14 +598,14 @@ function updateShareHint() {
|
|||||||
// Senden
|
// Senden
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function sendMessage(text) {
|
async function sendMessage(text, image) {
|
||||||
if (!state.current) await newSession();
|
if (!state.current) await newSession();
|
||||||
$('sendBtn').disabled = true;
|
$('sendBtn').disabled = true;
|
||||||
addUserMessage(text);
|
addUserMessage(text, image || null);
|
||||||
const res = await fetch(`/api/sessions/${state.current.id}/messages`, {
|
const res = await fetch(`/api/sessions/${state.current.id}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text }),
|
body: JSON.stringify(image ? { text, image } : { text }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -439,6 +613,7 @@ async function sendMessage(text) {
|
|||||||
$('sendBtn').disabled = false;
|
$('sendBtn').disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
clearImage();
|
||||||
enableNotifications();
|
enableNotifications();
|
||||||
showActivity();
|
showActivity();
|
||||||
setActivity('In Warteschlange…', '⏳');
|
setActivity('In Warteschlange…', '⏳');
|
||||||
@@ -462,6 +637,7 @@ window.addEventListener('load', async () => {
|
|||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadModels();
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const target = params.get('session') || state.sessions.find((s) => s.status === 'running')?.id || state.sessions[0]?.id;
|
const target = params.get('session') || state.sessions.find((s) => s.status === 'running')?.id || state.sessions[0]?.id;
|
||||||
@@ -476,10 +652,11 @@ window.addEventListener('load', async () => {
|
|||||||
$('chatForm').addEventListener('submit', (e) => {
|
$('chatForm').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = $('input').value.trim();
|
const text = $('input').value.trim();
|
||||||
if (!text) return;
|
const image = state.pendingImage;
|
||||||
|
if (!text && !image) return;
|
||||||
$('input').value = '';
|
$('input').value = '';
|
||||||
$('input').style.height = 'auto';
|
$('input').style.height = 'auto';
|
||||||
sendMessage(text);
|
sendMessage(text, image);
|
||||||
});
|
});
|
||||||
|
|
||||||
$('input').addEventListener('input', () => {
|
$('input').addEventListener('input', () => {
|
||||||
@@ -494,6 +671,18 @@ $('logoutBtn').onclick = async () => {
|
|||||||
location.href = '/login.html';
|
location.href = '/login.html';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$('modelSelectDesktop').addEventListener('change', (e) => changeModel(e.target.value));
|
||||||
|
$('modelSelectMobile').addEventListener('change', (e) => changeModel(e.target.value));
|
||||||
|
|
||||||
|
$('imageBtn').onclick = () => {
|
||||||
|
if (!$('imageBtn').disabled) $('imageInput').click();
|
||||||
|
};
|
||||||
|
$('imageInput').addEventListener('change', (e) => {
|
||||||
|
handleImageFile(e.target.files?.[0]);
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
$('imageRemoveBtn').onclick = clearImage;
|
||||||
|
|
||||||
// Enter sendet, Shift+Enter = Zeilenumbruch
|
// Enter sendet, Shift+Enter = Zeilenumbruch
|
||||||
$('input').addEventListener('keydown', (e) => {
|
$('input').addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|||||||
+18
-1
@@ -27,9 +27,14 @@
|
|||||||
<button id="logoutBtn" class="text-sm text-slate-400 hover:text-red-400" title="Abmelden">⎋</button>
|
<button id="logoutBtn" class="text-sm text-slate-400 hover:text-red-400" title="Abmelden">⎋</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="newSessionBtn"
|
<button id="newSessionBtn"
|
||||||
class="m-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm">
|
class="m-4 mb-2 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm">
|
||||||
+ Neue Recherche
|
+ Neue Recherche
|
||||||
</button>
|
</button>
|
||||||
|
<div class="mx-4 mb-2">
|
||||||
|
<label class="text-xs text-slate-400 block mb-1" for="modelSelectDesktop">Modell</label>
|
||||||
|
<select id="modelSelectDesktop"
|
||||||
|
class="w-full px-2 py-1.5 rounded-lg border text-sm dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500"></select>
|
||||||
|
</div>
|
||||||
<div id="sessionList" class="flex-1 overflow-y-auto px-2 space-y-1"></div>
|
<div id="sessionList" class="flex-1 overflow-y-auto px-2 space-y-1"></div>
|
||||||
<div class="p-3 text-xs text-slate-400 border-t dark:border-slate-800 border-slate-200">
|
<div class="p-3 text-xs text-slate-400 border-t dark:border-slate-800 border-slate-200">
|
||||||
<div id="userName" class="font-medium text-slate-500 dark:text-slate-400 mb-1"></div>
|
<div id="userName" class="font-medium text-slate-500 dark:text-slate-400 mb-1"></div>
|
||||||
@@ -43,6 +48,8 @@
|
|||||||
<div class="md:hidden flex items-center gap-2 p-3 border-b dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
|
<div class="md:hidden flex items-center gap-2 p-3 border-b dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
|
||||||
<button id="menuBtn" class="px-2 py-1 rounded bg-slate-200 dark:bg-slate-800">☰</button>
|
<button id="menuBtn" class="px-2 py-1 rounded bg-slate-200 dark:bg-slate-800">☰</button>
|
||||||
<span class="font-bold truncate flex-1" id="mobileTitle">Research Agent</span>
|
<span class="font-bold truncate flex-1" id="mobileTitle">Research Agent</span>
|
||||||
|
<select id="modelSelectMobile"
|
||||||
|
class="px-2 py-1 rounded-lg border text-xs dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity bar -->
|
<!-- Activity bar -->
|
||||||
@@ -50,6 +57,8 @@
|
|||||||
<div id="activityLine" class="flex items-center gap-2">
|
<div id="activityLine" class="flex items-center gap-2">
|
||||||
<span class="inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
|
<span class="inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
|
||||||
<span id="activityText">Starte…</span>
|
<span id="activityText">Starte…</span>
|
||||||
|
<button id="abortBtn" type="button" title="Recherche abbrechen"
|
||||||
|
class="ml-auto hidden text-xs px-2 py-1 rounded-lg bg-red-600/15 text-red-400 hover:bg-red-600/30">⛔ Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="activityLog" class="hidden mt-1 max-h-32 overflow-y-auto text-xs text-slate-400 space-y-0.5"></div>
|
<div id="activityLog" class="hidden mt-1 max-h-32 overflow-y-auto text-xs text-slate-400 space-y-0.5"></div>
|
||||||
<button id="toggleLog" class="text-xs text-blue-400 mt-1">Details zeigen</button>
|
<button id="toggleLog" class="text-xs text-blue-400 mt-1">Details zeigen</button>
|
||||||
@@ -69,7 +78,15 @@
|
|||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<div class="p-4 border-t dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
|
<div class="p-4 border-t dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
|
||||||
|
<div id="imagePreview" class="hidden mb-2 relative">
|
||||||
|
<img id="imagePreviewImg" src="" alt="Vorschau" class="max-h-40 rounded-xl border dark:border-slate-700 border-slate-300">
|
||||||
|
<button id="imageRemoveBtn" type="button" title="Bild entfernen"
|
||||||
|
class="absolute -top-2 -left-2 w-6 h-6 rounded-full bg-red-600 text-white text-xs font-bold">✕</button>
|
||||||
|
</div>
|
||||||
<form id="chatForm" class="flex gap-2 items-end">
|
<form id="chatForm" class="flex gap-2 items-end">
|
||||||
|
<input type="file" id="imageInput" accept="image/jpeg,image/png,image/webp" class="hidden">
|
||||||
|
<button id="imageBtn" type="button" title="Bild anhängen (nur Vision-Modelle)"
|
||||||
|
class="px-3 py-2.5 rounded-xl border dark:border-slate-700 border-slate-300 dark:bg-slate-800 hover:dark:bg-slate-700 text-lg leading-none">📷</button>
|
||||||
<textarea id="input" rows="1" placeholder="Thema eingeben… z.B. „Beste Self-Hosted Social-Media-Alternativen 2026“"
|
<textarea id="input" rows="1" placeholder="Thema eingeben… z.B. „Beste Self-Hosted Social-Media-Alternativen 2026“"
|
||||||
class="flex-1 resize-none px-3 py-2.5 rounded-xl border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-40"></textarea>
|
class="flex-1 resize-none px-3 py-2.5 rounded-xl border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-40"></textarea>
|
||||||
<button id="sendBtn" type="submit"
|
<button id="sendBtn" type="submit"
|
||||||
|
|||||||
+5
-3
@@ -28,7 +28,7 @@
|
|||||||
<div id="content" class="hidden space-y-4"></div>
|
<div id="content" class="hidden space-y-4"></div>
|
||||||
|
|
||||||
<footer class="mt-8 text-center text-xs text-slate-500">
|
<footer class="mt-8 text-center text-xs text-slate-500">
|
||||||
<p>Generiert mit dem Research Agent · DeepSeek v4 Flash</p>
|
<p>Generiert mit dem Research Agent · DeepSeek V4.1 Flash</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toolLabel = (m) => {
|
const toolLabel = (m) => {
|
||||||
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' };
|
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊', screenshot: '📷' };
|
||||||
const icon = icons[m.tool_name] || '🔧';
|
const icon = icons[m.tool_name] || '🔧';
|
||||||
const text = m.tool_name === 'search' ? `Suche: ${m.content}` : m.content;
|
const text = m.tool_name === 'search' ? `Suche: ${m.content}` : m.content;
|
||||||
return { icon, text: (text || '').slice(0, 200) };
|
return { icon, text: (text || '').slice(0, 200) };
|
||||||
@@ -90,7 +90,9 @@
|
|||||||
chat.className = 'space-y-3 mt-2';
|
chat.className = 'space-y-3 mt-2';
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (m.role === 'user') {
|
if (m.role === 'user') {
|
||||||
chat.innerHTML += `<div class="flex justify-end"><div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`;
|
chat.innerHTML += `<div class="flex justify-end"><div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${
|
||||||
|
m.image ? `<img src="${m.image}" alt="Angehängtes Bild" class="rounded-lg mb-2 max-h-48">` : ''
|
||||||
|
}${mdLite(m.content)}</div></div>`;
|
||||||
} else if (m.role === 'assistant') {
|
} else if (m.role === 'assistant') {
|
||||||
if (m.content)
|
if (m.content)
|
||||||
chat.innerHTML += `<div class="flex"><div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`;
|
chat.innerHTML += `<div class="flex"><div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const webpush = require('web-push');
|
const webpush = require('web-push');
|
||||||
|
|
||||||
const { config } = require('./lib/config');
|
const { config, getModel, getModels } = require('./lib/config');
|
||||||
const db = require('./lib/db');
|
const db = require('./lib/db');
|
||||||
const queue = require('./lib/queue');
|
const queue = require('./lib/queue');
|
||||||
const { runResearchJob } = require('./lib/research');
|
const { runResearchJob } = require('./lib/research');
|
||||||
const { closeBrowser } = require('./lib/tools');
|
const { closeBrowser, deleteSessionFiles } = require('./lib/tools');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
@@ -159,10 +160,36 @@ app.get('/api/sessions', requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/sessions', requireAuth, (req, res) => {
|
app.post('/api/sessions', requireAuth, (req, res) => {
|
||||||
const session = db.createSession(req.user.n);
|
const session = db.createSession(req.user.n, 'Neue Recherche', getModel(config.model).id);
|
||||||
res.json(session);
|
res.json(session);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/sessions/:id', requireAuth, (req, res) => {
|
||||||
|
const session = db.getSession(req.params.id);
|
||||||
|
if (!session) {
|
||||||
|
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) => {
|
app.get('/api/sessions/:id', requireAuth, (req, res) => {
|
||||||
const session = db.getSession(req.params.id);
|
const session = db.getSession(req.params.id);
|
||||||
if (!session || (session.owner_name !== req.user.n && !session.share_token)) {
|
if (!session || (session.owner_name !== req.user.n && !session.share_token)) {
|
||||||
@@ -180,9 +207,35 @@ app.get('/api/sessions/:id/share', requireAuth, (req, res) => {
|
|||||||
|
|
||||||
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
|
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
|
||||||
const session = db.getSession(req.params.id);
|
const session = db.getSession(req.params.id);
|
||||||
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
|
if (!session) {
|
||||||
|
return res.status(404).json({ error: 'Session nicht gefunden' });
|
||||||
|
}
|
||||||
queue.cancelQueued(session.id);
|
queue.cancelQueued(session.id);
|
||||||
|
queue.abortRunning(session.id);
|
||||||
db.deleteSession(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) {
|
||||||
|
return res.status(404).json({ error: 'Session nicht gefunden' });
|
||||||
|
}
|
||||||
|
if (queue.queuedBefore(session.id) >= 0) {
|
||||||
|
// Job wartet noch in der Queue → direkt entfernen und aufräumen
|
||||||
|
queue.cancelQueued(session.id);
|
||||||
|
} else if (queue.hasController(session.id)) {
|
||||||
|
// Laufender Job → Controller auslösen, der Job räumt selbst auf
|
||||||
|
queue.abortRunning(session.id);
|
||||||
|
return res.json({ ok: true });
|
||||||
|
}
|
||||||
|
// Wartender Job oder veralteter Job (z.B. nach Server-Neustart, ohne
|
||||||
|
// laufenden Prozess): Zustand und UI direkt zurücksetzen
|
||||||
|
db.createMessage(session.id, { role: 'assistant', content: '⛔ Recherche abgebrochen.' });
|
||||||
|
db.setSessionStatus(session.id, 'done');
|
||||||
|
emitToSession(session.id, 'aborted', {});
|
||||||
|
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +257,22 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
|
|||||||
const session = db.getSession(req.params.id);
|
const session = db.getSession(req.params.id);
|
||||||
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
|
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
|
||||||
const text = String(req.body?.text || '').trim();
|
const text = String(req.body?.text || '').trim();
|
||||||
if (!text) return res.status(400).json({ error: 'Leere Nachricht' });
|
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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (session.status === 'running' || session.status === 'queued') {
|
if (session.status === 'running' || session.status === 'queued') {
|
||||||
return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' });
|
return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' });
|
||||||
}
|
}
|
||||||
@@ -212,15 +280,18 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
|
|||||||
return res.status(409).json({ error: 'Eine andere Recherche läuft gerade – bitte warten.' });
|
return res.status(409).json({ error: 'Eine andere Recherche läuft gerade – bitte warten.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.createMessage(session.id, { role: 'user', content: text });
|
db.createMessage(session.id, { role: 'user', content: text, image });
|
||||||
if (session.title === 'Neue Recherche') {
|
if (session.title === 'Neue Recherche') {
|
||||||
db.setSessionTitle(session.id, text.slice(0, 70));
|
db.setSessionTitle(session.id, text.slice(0, 70));
|
||||||
}
|
}
|
||||||
db.setSessionStatus(session.id, 'queued');
|
db.setSessionStatus(session.id, 'queued');
|
||||||
|
|
||||||
|
const controller = queue.registerController(session.id);
|
||||||
|
|
||||||
const job = {
|
const job = {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
async run() {
|
async run() {
|
||||||
|
try {
|
||||||
db.setSessionStatus(session.id, 'running');
|
db.setSessionStatus(session.id, 'running');
|
||||||
emitToSession(session.id, 'status', { sessionStatus: 'running', jobStatus: 'running' });
|
emitToSession(session.id, 'status', { sessionStatus: 'running', jobStatus: 'running' });
|
||||||
|
|
||||||
@@ -234,16 +305,24 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
|
|||||||
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'done' });
|
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'done' });
|
||||||
sendPush(session, result);
|
sendPush(session, result);
|
||||||
},
|
},
|
||||||
|
aborted: () => {
|
||||||
|
emitToSession(session.id, 'aborted', {});
|
||||||
|
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' });
|
||||||
|
},
|
||||||
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
|
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
|
||||||
};
|
};
|
||||||
|
|
||||||
await runResearchJob(
|
await runResearchJob(
|
||||||
{ apiKey: config.apiKey, model: config.model, thinking: config.thinking, baseUrl: config.baseUrl },
|
config,
|
||||||
db.getSession(session.id),
|
db.getSession(session.id),
|
||||||
text,
|
text,
|
||||||
emit,
|
emit,
|
||||||
db
|
db,
|
||||||
|
controller.signal
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
queue.clearController(session.id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async onError(e) {
|
async onError(e) {
|
||||||
console.error('Job-Fehler:', e);
|
console.error('Job-Fehler:', e);
|
||||||
@@ -338,7 +417,87 @@ function sendPush(session, result) {
|
|||||||
// Statische Dateien & Dashboard-Dateien
|
// Statische Dateien & Dashboard-Dateien
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
app.use('/dashboards', express.static(path.join(__dirname, 'dashboards'), { maxAge: '1h' }));
|
// Wird in jedes ausgelieferte Dashboard-HTML injiziert: speichert den Zustand
|
||||||
|
// der Checklisten-Häkchen im localStorage und stellt ihn beim Laden wieder her
|
||||||
|
// (inkl. „Liste zurücksetzen"-Button). Key = Label-Text der Checkbox, damit
|
||||||
|
// der Stand auch bei Layout-Änderungen stabil bleibt.
|
||||||
|
const CHECKLIST_SCRIPT = `<script>
|
||||||
|
(function () {
|
||||||
|
var KEY = 'rc-checks:' + location.pathname;
|
||||||
|
var boxes = document.querySelectorAll('input[type=checkbox]');
|
||||||
|
if (!boxes.length) return;
|
||||||
|
var saved = {};
|
||||||
|
try { saved = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch (e) {}
|
||||||
|
|
||||||
|
function labelFor(box, i) {
|
||||||
|
var li = box.closest('li');
|
||||||
|
var t = li ? li.querySelector('.check-text') : null;
|
||||||
|
var text = (t || li || {}).textContent || '';
|
||||||
|
return text.trim() || 'item-' + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes.forEach(function (box, i) {
|
||||||
|
var k = labelFor(box, i);
|
||||||
|
if (saved[k] !== undefined) box.checked = !!saved[k];
|
||||||
|
box.addEventListener('change', function () {
|
||||||
|
saved[k] = box.checked;
|
||||||
|
try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (typeof update === 'function') update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// „Liste zurücksetzen"-Button am Anfang der ersten Checklisten-Section
|
||||||
|
var firstBox = boxes[0];
|
||||||
|
var section = firstBox.closest('section') || firstBox.closest('div') || document.body;
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = '↺ Liste zurücksetzen';
|
||||||
|
btn.style.cssText =
|
||||||
|
'margin:0 0 12px;padding:6px 14px;font-size:12px;font-weight:600;border-radius:8px;' +
|
||||||
|
'border:1px solid rgba(100,116,139,.4);background:transparent;color:inherit;cursor:pointer;';
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!confirm('Alle Häkchen dieser Liste zurücksetzen?')) return;
|
||||||
|
try { localStorage.removeItem(KEY); } catch (e) {}
|
||||||
|
saved = {};
|
||||||
|
boxes.forEach(function (b) { b.checked = false; });
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
section.insertBefore(btn, section.firstChild);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
})();
|
||||||
|
</script>`;
|
||||||
|
|
||||||
|
const CHECKLIST_MARKER = 'rc-checks';
|
||||||
|
|
||||||
|
app.get('/dashboards/:name.html', (req, res, next) => {
|
||||||
|
const name = path.basename(req.params.name);
|
||||||
|
const file = path.join(__dirname, 'dashboards', `${name}.html`);
|
||||||
|
fs.readFile(file, 'utf8', (err, html) => {
|
||||||
|
if (err) return next();
|
||||||
|
if (!html.includes(CHECKLIST_MARKER)) {
|
||||||
|
html = html.replace(/<\/body>/i, `${CHECKLIST_SCRIPT}\n</body>`);
|
||||||
|
}
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.type('html').send(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('/manifest.json', express.static(path.join(__dirname, 'public', 'manifest.json')));
|
||||||
app.use('/sw.js', express.static(path.join(__dirname, 'public', 'sw.js')));
|
app.use('/sw.js', express.static(path.join(__dirname, 'public', 'sw.js')));
|
||||||
app.use('/icons', express.static(path.join(__dirname, 'public', 'icons')));
|
app.use('/icons', express.static(path.join(__dirname, 'public', 'icons')));
|
||||||
@@ -360,8 +519,15 @@ app.get('/', (req, res) => {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Nach einem Server-Neustart hängengebliebene Jobs (running/queued in der DB)
|
||||||
|
// zurücksetzen – die Jobs existieren im Speicher nicht mehr.
|
||||||
|
const staleCount = db.markStaleJobs();
|
||||||
|
if (staleCount > 0) {
|
||||||
|
console.log(`INFO: ${staleCount} verwaiste Recherche(n) nach Neustart zurückgesetzt.`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – Recherche wird nicht funktionieren.');
|
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – DeepSeek-Modelle werden nicht funktionieren.');
|
||||||
}
|
}
|
||||||
if (!config.appPassword) {
|
if (!config.appPassword) {
|
||||||
console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.');
|
console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.');
|
||||||
|
|||||||
Reference in new issue
Block a user