Files
gott/wwwroot/js/chat.js
T
Meleki 22d2df01d0 fix: chain-meta mit In:0/Out:0 nicht mehr anzeigen während Stream läuft
- addChainMetaBubble unterdrückt Anzeige wenn inputTokens, outputTokens und durationMs alle 0 sind (Chain noch nicht finalisiert)
- done-Event: chain-meta nur noch in _lastDoneChainData merken, nicht sofort als streamed-Bubble anzeigen – wird erst im finally-Block nach pollHistory eingefügt
- behebt das Problem bei Chat-Navigation während laufendem Stream
2026-02-28 23:57:02 +01:00

320 lines
9.8 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(d.model || '?');
badges.push('In: ' + (d.inputTokens || 0));
badges.push('Out: ' + (d.outputTokens || 0));
if (d.cacheCreationTokens > 0) badges.push('Cache-Write: ' + d.cacheCreationTokens);
if (d.cacheReadTokens > 0) badges.push('Cache-Read: ' + d.cacheReadTokens);
if ((d.iterations || 0) > 1) badges.push('Iterations: ' + 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;
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 ──
const messages = [];
let isStreaming = false;
let pendingFiles = []; // [{ name, type, base64 }]
let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
async function clearChat() {
const archive = await apiArchive(getCurrentSessionId());
if (!archive) return;
messages.length = 0;
chatEl().innerHTML = '';
lastKnownMessageId = 0;
loadedArchiveCount = 0;
setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`);
showArchiveLoadButton();
}
// ── 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;
// Refs in den Text einbauen
let msgText = text;
if (pendingRefs.length > 0) {
const refTags = pendingRefs.map(r => {
if (r.from && r.to) return `[Datei: ${r.path}:${r.from}-${r.to}]`;
return `[Datei: ${r.path}]`;
});
msgText = refTags.join('\n') + (text ? '\n' + text : '');
clearPendingRefs();
}
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
});
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;
}
messages.push({ role: 'user', content });
addUserBubble(bubbleText, true);
input.value = '';
input.dispatchEvent(new Event('input'))
btn.disabled = true;
isStreaming = true;
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
let fullText = '';
let currentToolBox = null;
try {
const resp = await apiChat(messages, getCurrentSessionId());
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = null;
while (true) {
const { done, value } = await reader.read();
if (done) 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();
currentToolBox = addToolBoxLoading(data.name);
break;
case 'tool_end':
finishToolBox(currentToolBox, data);
currentToolBox = null;
break;
case 'error':
endStreamBubble();
addErrorBubble(data.error, true);
// Fehlgeschlagene Message aus dem Array entfernen
// damit das kaputte Bild nicht endlos mitgeschickt wird
if (messages.length > 0 && messages[messages.length - 1].role === 'user') {
messages.pop();
}
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) {
endStreamBubble();
// 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 {
if (!isStreaming || !_streamingPollInterval) {
btn.disabled = false;
isStreaming = false;
}
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" oder "path:from-to"
const match = ref.match(/^(.+?):(\d+)-(\d+)$/);
if (match) {
pendingRefs.push({ path: match[1], from: parseInt(match[2]), to: parseInt(match[3]) });
} 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.path}:${r.from}-${r.to}` : 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 = '';
}