- 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
636 lines
21 KiB
JavaScript
636 lines
21 KiB
JavaScript
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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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();
|
||
}
|
||
});
|