Files
gott/wwwroot/js/chat.js
T
Meleki 8fc29c82d9 refactor: Chat-Payload vereinfacht + Inline-Bilder statt Platzhalter
Backend:
- ChatEndpoint empfängt nur noch content + sessionId (nicht mehr die gesamte History)
- History wird komplett aus DB aufgebaut via BuildHistoryFromDb
- Gelöschte Nachrichten (deleted_on) werden aus History gefiltert
- Leere Nachrichten werden übersprungen (kein API 400 mehr)
- BuildHistoryFromDb optimiert: nur Text für ältere Chains, Tool-Calls nur für aktuelle Chain
- GetChainMessages: includeDeleted-Parameter (Default: false)
- Archiv-Export nutzt includeDeleted: true

Frontend:
- messages[]-Array komplett entfernt (kein Client-seitiger History-State mehr)
- apiChat() sendet nur content + sessionId
- Inline-Bilder: [Bild: name] wird durch <img> ersetzt (live mit DataURL, History per API-Nachladen)
- _autoLoadImageAttachments() für History/Archiv/ältere Nachrichten
- Klick auf Inline-Bild öffnet Vollbild-Modal
2026-03-04 14:29:33 +01:00

440 lines
14 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.
// ── Helpers ──
function buildMetaBadges(d) {
const badges = [];
badges.push(prettyModelName(d.model));
badges.push('Tokens: ' + (d.inputTokens || 0) + ' / ' + (d.outputTokens || 0));
if (d.cacheCreationTokens > 0 || d.cacheReadTokens > 0) {
badges.push('Cache: ' + (d.cacheReadTokens || 0) + ' / ' + (d.cacheCreationTokens || 0));
}
if ((d.iterations || 0) > 1) badges.push('Iterationen: ' + d.iterations);
if (d.durationMs) badges.push((d.durationMs / 1000).toFixed(1) + 's');
if (d.stopReason && d.stopReason !== 'end_turn' && d.stopReason !== 'tool_use') badges.push('Stop: ' + d.stopReason);
return badges;
}
function addChainMetaBubble(d, streamed) {
if (!d || !d.model) return;
// Chain noch nicht finalisiert (läuft noch) → nicht anzeigen
if (!d.inputTokens && !d.outputTokens && !d.durationMs) return;
if (!isChainMetaVisible(d)) return;
const badges = buildMetaBadges(d);
const div = document.createElement('div');
div.className = 'chain-meta';
if (streamed) div.classList.add('streamed');
div.innerHTML = badges.map(b => '<span class="chain-meta-badge">' + esc(b) + '</span>').join('');
chatEl().appendChild(div);
scrollDown();
}
// ── Chat State ──
let isStreaming = false;
let pendingFiles = []; // [{ name, type, base64 }]
let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen
let pendingReply = null; // { index, role, text } - Zitierte Nachricht
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft
let _abortController = null; // AbortController für laufenden Stream
async function clearChat() {
const archive = await apiArchive(getCurrentSessionId());
if (!archive) return;
chatEl().innerHTML = '';
lastKnownMessageId = 0;
loadedArchiveCount = 0;
_oldestLoadedMessageId = 0;
_hasMoreOlderMessages = false;
clearReply();
setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`);
showArchiveLoadButton();
}
function stopStreaming() {
if (_abortController) {
_abortController.abort();
}
}
// ── Send Message ──
async function sendMessage() {
const input = $('#input');
const btn = $('#send');
const text = input.value.trim();
if (!text && pendingFiles.length === 0 && pendingRefs.length === 0) return;
// Content bauen: string oder array mit file-blocks
let content;
let bubbleText = text;
// Reply-Zitat einbauen
let msgText = text;
if (pendingReply) {
const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant';
const timeLabel = _formatReplyTimestamp(pendingReply.timestamp);
const quoteText = pendingReply.text.split('\n').map(l => '> ' + l).join('\n');
const quoteHeader = `> [Zitat: ${timeLabel}, ${roleLabel}]:`;
msgText = quoteHeader + '\n' + quoteText + '\n\n' + (text || '');
clearReply();
}
// Refs in den Text einbauen
if (pendingRefs.length > 0) {
const refLines = pendingRefs.map(r => {
if (r.from && r.to && r.from !== r.to) return `[Datei: ${r.path}:${r.from}-${r.to}]`;
if (r.from) return `[Datei: ${r.path}:${r.from}]`;
return `[Datei: ${r.path}]`;
});
msgText = refLines.join(' ') + '\n' + msgText;
if (pendingRefs.some(r => r.from)) {
if (pendingRefs.length > 1)
msgText += `\n<hint>Lies referenzierte Dateien mit \`cat -n\`.</hint>`;
else
msgText += `\n<hint>Lies die referenzierte Datei mit \`cat -n\`.</hint>`;
}
clearPendingRefs();
}
let inlineImages = []; // Bilder für Inline-Anzeige
if (pendingFiles.length > 0) {
content = [];
const labels = [];
for (const f of pendingFiles) {
const blockType = f.type.startsWith('image/') ? 'image' : 'document';
content.push({
type: blockType,
source: { type: 'base64', media_type: f.type, data: f.base64 },
_file_name: f.name
});
if (blockType === 'image') {
inlineImages.push({ name: f.name, dataUrl: 'data:' + f.type + ';base64,' + f.base64 });
}
labels.push('[' + (blockType === 'image' ? 'Bild' : 'Datei') + ': ' + f.name + ']');
}
if (msgText) content.push({ type: 'text', text: msgText });
bubbleText = labels.join('\n') + (msgText ? '\n' + msgText : '');
clearPendingFiles();
} else {
content = msgText;
bubbleText = msgText;
}
addUserBubble(bubbleText, true, '', null, false, null, inlineImages);
input.value = '';
input.dispatchEvent(new Event('input'))
btn.disabled = true;
btn.hidden = true;
const stopBtn = $('#stop');
stopBtn.hidden = false;
isStreaming = true;
_abortController = new AbortController();
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
let fullText = '';
let currentToolBox = null;
_chatAbandoned = false;
try {
const resp = await apiChat(content, getCurrentSessionId(), _abortController.signal);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = null;
while (true) {
const { done, value } = await reader.read();
if (done || _chatAbandoned) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.substring(7).trim();
} else if (line.startsWith('data: ') && eventType) {
let data;
try { data = JSON.parse(line.substring(6)); } catch { continue; }
switch (eventType) {
case 'text_start':
endStreamBubble();
startStreamBubble();
break;
case 'text_delta':
appendToStream(data.text);
fullText += data.text;
break;
case 'text_end':
endStreamBubble();
break;
case 'tool_start':
endStreamBubble();
if (isToolVisible(data.name)) {
currentToolBox = addToolBoxLoading(data.name);
} else {
currentToolBox = null;
}
break;
case 'tool_end':
if (currentToolBox) {
finishToolBox(currentToolBox, data);
}
currentToolBox = null;
break;
case 'web_search_start':
endStreamBubble();
currentToolBox = addToolBoxLoading('web_search');
break;
case 'web_search_end':
if (currentToolBox) {
finishToolBox(currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false });
}
currentToolBox = null;
break;
case 'error':
endStreamBubble();
addErrorBubble(data.error, true);
break;
case 'done':
endStreamBubble();
if (data.stopReason === 'max_tokens') {
addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true);
}
// Chain-Meta nur merken, nicht sofort anzeigen –
// wird erst im finally nach pollHistory eingefügt
_lastDoneChainData = null;
if (data.model) {
_lastDoneChainData = data;
} else if (data.chainId) {
try {
const chain = await apiGetChain(data.chainId);
if (chain) _lastDoneChainData = chain;
} catch (_) {}
}
setMeta('Bereit');
break;
}
eventType = null;
}
}
}
} catch (e) {
// Bei Navigation nichts tun – der neue Page-Init übernimmt
if (_chatAbandoned) return;
endStreamBubble();
// User hat Stream abgebrochen
if (e.name === 'AbortError') {
setMeta('Abgebrochen');
// Kurz warten damit Backend den Disconnect merkt
await new Promise(r => setTimeout(r, 500));
} else {
// Prüfen ob Backend noch streamt (z.B. bei Netzwerkfehler)
try {
const status = await apiIsStreaming(getCurrentSessionId());
if (status.streaming) {
// Backend streamt noch weiter – Recovery-Poll starten
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming... (Verbindung wiederhergestellt)</span>');
startStreamingPoll(getCurrentSessionId(), btn, status.chainId);
return;
}
} catch (_) {}
addErrorBubble('Netzwerk-Fehler: ' + e.message);
}
} finally {
// Bei Navigation nichts aufräumen – der neue Page-Init übernimmt
if (_chatAbandoned) return;
if (!isStreaming || !_streamingPollInterval) {
btn.disabled = false;
btn.hidden = false;
stopBtn.hidden = true;
isStreaming = false;
_abortController = null;
}
endStreamBubble();
// Gestreamte Elemente entfernen, dann aus DB laden, dann Chain-Meta anhängen
removeStreamedElements();
await pollHistory();
if (_lastDoneChainData) {
addChainMetaBubble(_lastDoneChainData, false);
_lastDoneChainData = null;
}
}
}
// ── File Handling ──
function handleFileSelect(file) {
if (!file) return;
const maxSize = 20 * 1024 * 1024; // 20MB
if (file.size > maxSize) {
alert('Datei zu groß (max. 20MB)');
return;
}
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
if (!allowed.includes(file.type)) {
alert('Nicht unterstützter Dateityp. Erlaubt: JPEG, PNG, GIF, WebP, PDF');
return;
}
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
pendingFiles.push({ name: file.name, type: file.type, base64 });
renderFilePreview();
};
reader.readAsDataURL(file);
}
function handleMultipleFiles(files) {
for (const file of files) handleFileSelect(file);
}
function renderFilePreview() {
const preview = $('#filePreview');
if (!preview || pendingFiles.length === 0) {
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
return;
}
preview.innerHTML = pendingFiles.map((f, i) => {
const isImage = f.type.startsWith('image/');
return `<div class="file-preview-inner">
${isImage ? '<img src="data:' + f.type + ';base64,' + f.base64 + '" class="file-thumb">' : '<span class="file-icon"><i class="fa fa-file-text-o"></i></span>'}
<span class="file-name">${esc(f.name)}</span>
<button class="file-remove" onclick="removePendingFile(${i})"><i class="fa fa-times"></i></button>
</div>`;
}).join('');
preview.hidden = false;
}
function removePendingFile(index) {
pendingFiles.splice(index, 1);
renderFilePreview();
}
// ── Workspace Refs ──
function addPendingRef(ref) {
// ref = "path", "path:line" oder "path:from-to"
const rangeMatch = ref.match(/^(.+?):(\d+)-(\d+)$/);
const lineMatch = !rangeMatch && ref.match(/^(.+?):(\d+)$/);
if (rangeMatch) {
pendingRefs.push({ path: rangeMatch[1], from: parseInt(rangeMatch[2]), to: parseInt(rangeMatch[3]) });
} else if (lineMatch) {
pendingRefs.push({ path: lineMatch[1], from: parseInt(lineMatch[2]), to: parseInt(lineMatch[2]) });
} else {
pendingRefs.push({ path: ref, from: null, to: null });
}
renderRefPreview();
}
function removePendingRef(index) {
pendingRefs.splice(index, 1);
renderRefPreview();
}
function clearPendingRefs() {
pendingRefs.length = 0;
renderRefPreview();
}
function renderRefPreview() {
const container = $('#refPreview');
if (!container) return;
if (pendingRefs.length === 0) {
container.innerHTML = '';
container.hidden = true;
return;
}
container.innerHTML = pendingRefs.map((r, i) => {
const label = r.from && r.to && r.from !== r.to ? `${r.path}:${r.from}-${r.to}` : r.from ? `${r.path}:${r.from}` : r.path;
return `<div class="ref-preview-item">
<span class="ref-icon"><i class="fa fa-folder-open"></i></span>
<span class="ref-label">${esc(label)}</span>
<button class="file-remove" onclick="removePendingRef(${i})"><i class="fa fa-times"></i></button>
</div>`;
}).join('');
container.hidden = false;
}
function clearPendingFiles() {
pendingFiles.length = 0;
const preview = $('#filePreview');
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
const fileInput = $('#fileInput');
if (fileInput) fileInput.value = '';
}
// ── Reply / Quote ──
function _formatReplyTimestamp(ts) {
if (!ts) return 'Jetzt';
try {
const d = new Date(ts);
const now = new Date();
const hh = d.getHours().toString().padStart(2, '0');
const mm = d.getMinutes().toString().padStart(2, '0');
// Gleiches Datum → nur Uhrzeit, sonst Datum + Uhrzeit
if (d.toDateString() === now.toDateString()) {
return hh + ':' + mm;
}
const dd = d.getDate().toString().padStart(2, '0');
const mo = (d.getMonth() + 1).toString().padStart(2, '0');
return dd + '.' + mo + '. ' + hh + ':' + mm;
} catch { return 'Jetzt'; }
}
function setReply(reply) {
pendingReply = reply;
renderReplyPreview();
document.getElementById('input')?.focus();
}
function clearReply() {
pendingReply = null;
renderReplyPreview();
}
function renderReplyPreview() {
const container = $('#replyPreview');
if (!container) return;
if (!pendingReply) {
container.innerHTML = '';
container.hidden = true;
return;
}
const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant';
const timeLabel = _formatReplyTimestamp(pendingReply.timestamp);
const preview = truncate(pendingReply.text, 150);
container.innerHTML = `<div class="reply-preview-item">
<span class="reply-icon"><i class="fa fa-reply"></i></span>
<span class="reply-meta">${esc(timeLabel)}, ${esc(roleLabel)}</span>
<span class="reply-text">${esc(preview)}</span>
<button class="file-remove" onclick="clearReply()"><i class="fa fa-times"></i></button>
</div>`;
container.hidden = false;
}