Files
researchChat/public/app.js
T
nico 87eb7029f1 Multi-Modell + Vision: OpenRouter (GLM 5.3 Flash), Bild-Upload, Tour-Feature, Dashboard-Fixes
- Modell-Registry (DeepSeek direkt + OpenRouter z-ai/glm-5.3-flash), Modellwahl pro Session, Reasoning-Effort max (env-konfigurierbar)
- Bild-Upload für Vision-Modelle (User) + Agent-screenshot()-Tool (nur Vision)
- Tour-Planung: geocode + save_tour mit GPX-Export und Karten-Sektion
- Fix: System-Prompt wird jetzt wirklich an die API gesendet, inkl. aktuellem Dashboard-Dateinamen für Folge-Updates
- Fix: History-Sanitizer gegen 400-Fehler (nie mitten in Tool-Gruppen kürzen)
- Fix: reasoning_content-Roundtrip (Pflicht bei DeepSeek mit tools)
- Fix: Dashboards ohne Browser-Cache ausliefern
2026-08-27 17:43:13 +02:00

636 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
me: null,
sessions: [],
current: null,
stream: null,
streamingEl: null,
activityShown: false,
models: [],
modelById: {},
pendingImage: null,
};
const $ = (id) => document.getElementById(id);
// ---------------------------------------------------------------------------
// Hilfsfunktionen
// ---------------------------------------------------------------------------
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function mdLite(text) {
let s = escapeHtml(text);
s = s.replace(/`([^`]+)`/g, '<code class="px-1 py-0.5 rounded bg-slate-700 text-amber-200 text-xs">$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline break-all">$1</a>');
s = s.replace(/https?:\/\/[^\s<]+/g, '<a href="$&" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline break-all">$&</a>');
return s.replace(/\n/g, '<br>');
}
function toolLabel(msg) {
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊', screenshot: '📷' };
const icon = icons[msg.tool_name] || '🔧';
const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content;
return { icon, text: text?.slice(0, 200) };
}
// ---------------------------------------------------------------------------
// Session-Liste / Sidebar
// ---------------------------------------------------------------------------
async function loadSessions() {
const res = await fetch('/api/sessions');
state.sessions = await res.json();
renderSessions();
}
function renderSessions() {
const list = $('sessionList');
list.innerHTML = '';
for (const s of state.sessions) {
const active = state.current?.id === s.id;
const dot =
s.job === 'running' || s.job === 'queued'
? '<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse inline-block mr-2"></span>'
: 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-slate-500 inline-block mr-2"></span>';
const btn = document.createElement('button');
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'
}`;
btn.innerHTML = `${dot}<span class="truncate">${escapeHtml(s.title)}</span>`;
btn.onclick = () => openSession(s.id);
list.appendChild(btn);
}
const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued');
$('queueStatus').classList.toggle('hidden', !qs);
}
// ---------------------------------------------------------------------------
// Chat-Rendering
// ---------------------------------------------------------------------------
function addUserMessage(text, image) {
$('emptyState')?.remove();
const el = document.createElement('div');
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">${
image ? `<img src="${image}" alt="Angehängtes Bild" class="rounded-lg mb-2 max-h-48">` : ''
}${mdLite(text)}</div>`;
$('chat').appendChild(el);
scrollDown();
}
function addAssistantBubble(text, streaming = false) {
$('emptyState')?.remove();
const el = document.createElement('div');
el.className = 'flex';
el.innerHTML = `<div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words ${
streaming ? 'streaming' : ''
}"></div>`;
const body = el.firstChild;
body.innerHTML = mdLite(text);
$('chat').appendChild(el);
scrollDown();
return body;
}
function addToolLine(icon, text) {
$('emptyState')?.remove();
const el = document.createElement('div');
el.className = 'flex justify-start';
el.innerHTML = `<div class="text-xs text-slate-400 px-1 flex items-center gap-1"><span>${icon}</span><span class="break-all">${escapeHtml(text)}</span></div>`;
$('chat').appendChild(el);
scrollDown();
}
function addDashboardCard(session) {
const url = session.dashboard;
if (!url) return;
const el = document.createElement('div');
el.className = 'flex';
el.innerHTML = `
<div class="max-w-[85%] w-full dark:bg-slate-800 bg-slate-200 rounded-2xl p-4 border dark:border-slate-700 border-slate-300">
<div class="text-sm font-semibold mb-2">📊 Dashboard bereit</div>
<a href="${url}" target="_blank" rel="noopener noreferrer"
class="inline-block px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Dashboard öffnen ↗</a>
<a href="${url}" download class="inline-block ml-2 px-4 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white text-sm">Download</a>
</div>`;
$('chat').appendChild(el);
scrollDown();
}
function addUsageLine(promptTokens, completionTokens, cost) {
const el = document.createElement('div');
el.className = 'flex justify-end';
el.innerHTML = `<div class="text-xs text-slate-500 dark:text-slate-500 px-1">🧮 ${formatTokens(promptTokens)} Eingabe · ${formatTokens(
completionTokens
)} Ausgabe · ≈ ${Number(cost || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 4 })} USD</div>`;
$('chat').appendChild(el);
scrollDown();
}
function formatTokens(n) {
return Number(n || 0).toLocaleString('de-DE');
}
function renderMessages(messages, session) {
$('chat').innerHTML = '';
if (!messages.length) {
const empty = document.createElement('div');
empty.className =
'h-full flex flex-col items-center justify-center text-center text-slate-400';
empty.innerHTML = `
<div class="text-5xl mb-4">🔎</div>
<h2 class="text-lg font-semibold text-slate-500 dark:text-slate-300">Worüber soll ich recherchieren?</h2>
<p class="text-sm max-w-md mt-2">
Ich suche gründlich im Internet, verifiziere alle Quellen und baue dir ein
Dashboard mit Analyse, Empfehlungen und Handlungsplan.
</p>`;
$('chat').appendChild(empty);
}
addDashboardCard(session);
for (const m of messages) {
if (m.role === 'user') addUserMessage(m.content, m.image || null);
else if (m.role === 'assistant') {
if (m.content) addAssistantBubble(m.content);
} else if (m.role === 'tool') {
const { icon, text } = toolLabel(m);
addToolLine(icon, text);
}
}
if (session.cost_estimate > 0) {
addUsageLine(session.prompt_tokens, session.completion_tokens, session.cost_estimate);
}
scrollDown();
}
function scrollDown() {
const chat = $('chat');
chat.scrollTop = chat.scrollHeight;
}
// ---------------------------------------------------------------------------
// Activity-Bar (Job-Fortschritt)
// ---------------------------------------------------------------------------
function showActivity() {
$('activityBar').classList.remove('hidden');
$('activityLog').classList.add('hidden');
$('toggleLog').textContent = 'Details zeigen';
state.activityShown = true;
}
function setActivity(text, icon = '') {
$('activityText').textContent = `${icon} ${text}`;
}
function logActivity(text) {
const div = document.createElement('div');
div.textContent = text;
$('activityLog').appendChild(div);
$('activityLog').scrollTop = $('activityLog').scrollHeight;
}
function hideActivity() {
$('activityBar').classList.add('hidden');
state.activityShown = false;
}
$('toggleLog').onclick = () => {
const log = $('activityLog');
const hidden = log.classList.toggle('hidden');
$('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen';
};
// ---------------------------------------------------------------------------
// SSE-Stream
// ---------------------------------------------------------------------------
function connectStream(sessionId) {
state.stream?.close();
const es = new EventSource(`/api/sessions/${sessionId}/stream`);
state.stream = es;
es.addEventListener('status', (e) => {
const d = JSON.parse(e.data);
if (d.sessionStatus === 'running' || d.jobStatus === 'queued') showActivity();
if (d.sessionStatus === 'done') {
setTimeout(hideActivity, 1500);
loadSessions();
}
});
es.addEventListener('progress', (e) => {
const d = JSON.parse(e.data);
showActivity();
setActivity(d.text, d.icon);
});
es.addEventListener('step', (e) => {
logActivity(JSON.parse(e.data).text);
});
es.addEventListener('thinking', (e) => {
const d = JSON.parse(e.data);
setActivity('Denke nach…', '🧠');
logActivity(d.text.slice(0, 300));
});
es.addEventListener('token', (e) => {
const d = JSON.parse(e.data);
showActivity();
setActivity('Schreibe Antwort…', '✍️');
if (!state.streamingEl) {
state.streamingEl = addAssistantBubble('', true);
}
state.streamingEl.innerHTML = mdLite(state.streamingEl.textContent + d.text);
scrollDown();
});
es.addEventListener('done', async (e) => {
const d = JSON.parse(e.data);
if (state.streamingEl) {
state.streamingEl.innerHTML = mdLite(d.text || state.streamingEl.textContent);
state.streamingEl = null;
} else if (d.text) {
addAssistantBubble(d.text);
}
hideActivity();
if (d.usage) {
addUsageLine(d.usage.promptTokens, d.usage.completionTokens, d.costEstimate);
}
if (d.dashboardUrl) {
const card = document.createElement('div');
card.className = 'flex';
card.innerHTML = `
<div class="max-w-[85%] w-full dark:bg-slate-800 bg-slate-200 rounded-2xl p-4 border dark:border-slate-700 border-slate-300">
<div class="text-sm font-semibold mb-2">📊 Dashboard bereit</div>
<a href="${d.dashboardUrl}" target="_blank" rel="noopener noreferrer"
class="inline-block px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Dashboard öffnen ↗</a>
<a href="${d.dashboardUrl}" download class="inline-block ml-2 px-4 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white text-sm">Download</a>
</div>`;
$('chat').appendChild(card);
scrollDown();
notify(`"${sessionTitle()}" – Dashboard ist bereit`);
}
loadSessions();
$('sendBtn').disabled = false;
});
es.addEventListener('error', (e) => {
if (!e.data) return;
const d = JSON.parse(e.data);
addToolLine('⚠️', d.text || 'Ein Fehler ist aufgetreten');
hideActivity();
$('sendBtn').disabled = false;
loadSessions();
});
es.onerror = () => {
setTimeout(() => {
if (state.current) connectStream(state.current.id);
}, 4000);
};
}
function sessionTitle() {
return state.current?.title || 'Recherche';
}
// ---------------------------------------------------------------------------
// Benachrichtigungen + Push
// ---------------------------------------------------------------------------
function notify(body) {
if ('Notification' in window && Notification.permission === 'granted') {
try {
new Notification('✅ Recherche fertig', { body, icon: '/icons/icon-192.png' });
} catch {}
}
}
async function enableNotifications() {
if (!('Notification' in window) || !('serviceWorker' in navigator)) return;
if (Notification.permission === 'denied') return;
if (Notification.permission !== 'granted') {
try {
await Notification.requestPermission();
} catch {
return;
}
}
try {
const reg = await navigator.serviceWorker.ready;
const { key } = await (await fetch('/api/push/vapid-key')).json();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key,
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
});
} catch (e) {
console.error('Push-Subscription fehlgeschlagen:', e);
}
}
// ---------------------------------------------------------------------------
// Session-Verwaltung
// ---------------------------------------------------------------------------
async function openSession(id) {
state.stream?.close();
state.streamingEl = null;
hideActivity();
clearImage();
const res = await fetch(`/api/sessions/${id}`);
const data = await res.json();
state.current = data.session;
if (!state.current.model) state.current.model = state.models[0]?.id || null;
renderMessages(data.messages, data.session);
$('mobileTitle').textContent = data.session.title;
setSelectModel(state.current.model);
updateModelUI();
renderSessions();
const q = state.sessions.find((s) => s.id === id);
if (data.session.status === 'running' || q?.job === 'running' || q?.job === 'queued') {
showActivity();
}
updateShareHint();
connectStream(id);
}
async function newSession() {
const res = await fetch('/api/sessions', { method: 'POST' });
const session = await res.json();
await loadSessions();
await openSession(session.id);
$('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) {
try {
navigator.clipboard.writeText(text).then(
() => {
btn.textContent = '✅ Link kopiert!';
setTimeout(() => (btn.textContent = label), 2500);
},
() => {
window.prompt(label, text);
btn.textContent = label;
}
);
} catch {
window.prompt(label, text);
btn.textContent = label;
}
}
function updateShareHint() {
$('shareHint').innerHTML = '';
if (!state.current) return;
const btn = document.createElement('button');
btn.className = 'text-blue-400 hover:underline text-xs';
btn.textContent = '🔗 Chat teilen';
btn.onclick = async () => {
const res = await fetch(`/api/sessions/${state.current.id}/share`);
if (!res.ok) {
btn.textContent = '⚠️ Fehler beim Teilen';
setTimeout(() => (btn.textContent = '🔗 Chat teilen'), 2500);
return;
}
const { fullUrl } = await res.json();
copyText(fullUrl, btn, '🔗 Chat teilen');
};
$('shareHint').appendChild(btn);
if (state.current.dashboard) {
$('shareHint').appendChild(document.createTextNode(' · '));
const dashBtn = document.createElement('button');
dashBtn.className = 'text-emerald-400 hover:underline text-xs';
dashBtn.textContent = '📊 Dashboard teilen';
dashBtn.onclick = async () => {
const full = `${location.origin}${state.current.dashboard}`;
copyText(full, dashBtn, '📊 Dashboard teilen');
};
$('shareHint').appendChild(dashBtn);
}
}
// ---------------------------------------------------------------------------
// Senden
// ---------------------------------------------------------------------------
async function sendMessage(text, image) {
if (!state.current) await newSession();
$('sendBtn').disabled = true;
addUserMessage(text, image || null);
const res = await fetch(`/api/sessions/${state.current.id}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(image ? { text, image } : { text }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
addToolLine('⚠️', data.error || 'Senden fehlgeschlagen');
$('sendBtn').disabled = false;
return;
}
clearImage();
enableNotifications();
showActivity();
setActivity('In Warteschlange…', '⏳');
await loadSessions();
const s = state.sessions.find((x) => x.id === state.current.id);
if (s?.title) state.current.title = s.title;
connectStream(state.current.id);
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
window.addEventListener('load', async () => {
const meRes = await fetch('/api/me');
if (!meRes.ok) return (location.href = '/login.html');
state.me = await meRes.json();
$('userName').textContent = `👤 ${state.me.name}`;
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
await loadModels();
await loadSessions();
const params = new URLSearchParams(location.search);
const target = params.get('session') || state.sessions.find((s) => s.status === 'running')?.id || state.sessions[0]?.id;
if (target) await openSession(target);
if (!target) {
const s = await (await fetch('/api/sessions', { method: 'POST' })).json();
await openSession(s.id);
}
});
$('chatForm').addEventListener('submit', (e) => {
e.preventDefault();
const text = $('input').value.trim();
const image = state.pendingImage;
if (!text && !image) return;
$('input').value = '';
$('input').style.height = 'auto';
sendMessage(text, image);
});
$('input').addEventListener('input', () => {
$('input').style.height = 'auto';
$('input').style.height = Math.min($('input').scrollHeight, 160) + 'px';
});
$('newSessionBtn').onclick = newSession;
$('menuBtn').onclick = () => $('sidebar').classList.toggle('hidden');
$('logoutBtn').onclick = async () => {
await fetch('/api/logout', { method: 'POST' });
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
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
$('chatForm').requestSubmit();
}
});