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
This commit is contained in:
1 parent
eee59363d4
commit
87eb7029f1
10 files changed
+843
-70
No files matched your search
+141
-9
@@ -5,6 +5,9 @@ const state = {
|
||||
stream: null,
|
||||
streamingEl: null,
|
||||
activityShown: false,
|
||||
models: [],
|
||||
modelById: {},
|
||||
pendingImage: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -32,7 +35,7 @@ function mdLite(text) {
|
||||
}
|
||||
|
||||
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 text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content;
|
||||
return { icon, text: text?.slice(0, 200) };
|
||||
@@ -75,11 +78,13 @@ function renderSessions() {
|
||||
// Chat-Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function addUserMessage(text) {
|
||||
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">${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);
|
||||
scrollDown();
|
||||
}
|
||||
@@ -154,7 +159,7 @@ function renderMessages(messages, session) {
|
||||
}
|
||||
addDashboardCard(session);
|
||||
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') {
|
||||
if (m.content) addAssistantBubble(m.content);
|
||||
} else if (m.role === 'tool') {
|
||||
@@ -348,11 +353,15 @@ 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') {
|
||||
@@ -370,6 +379,114 @@ async function newSession() {
|
||||
$('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(
|
||||
@@ -424,14 +541,14 @@ function updateShareHint() {
|
||||
// Senden
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendMessage(text) {
|
||||
async function sendMessage(text, image) {
|
||||
if (!state.current) await newSession();
|
||||
$('sendBtn').disabled = true;
|
||||
addUserMessage(text);
|
||||
addUserMessage(text, image || null);
|
||||
const res = await fetch(`/api/sessions/${state.current.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
body: JSON.stringify(image ? { text, image } : { text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -439,6 +556,7 @@ async function sendMessage(text) {
|
||||
$('sendBtn').disabled = false;
|
||||
return;
|
||||
}
|
||||
clearImage();
|
||||
enableNotifications();
|
||||
showActivity();
|
||||
setActivity('In Warteschlange…', '⏳');
|
||||
@@ -462,6 +580,7 @@ window.addEventListener('load', async () => {
|
||||
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;
|
||||
@@ -476,10 +595,11 @@ window.addEventListener('load', async () => {
|
||||
$('chatForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const text = $('input').value.trim();
|
||||
if (!text) return;
|
||||
const image = state.pendingImage;
|
||||
if (!text && !image) return;
|
||||
$('input').value = '';
|
||||
$('input').style.height = 'auto';
|
||||
sendMessage(text);
|
||||
sendMessage(text, image);
|
||||
});
|
||||
|
||||
$('input').addEventListener('input', () => {
|
||||
@@ -494,6 +614,18 @@ $('logoutBtn').onclick = async () => {
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user