Files
gott/wwwroot/js/chat.js
T
Meleki 873e40b5d1 Chain-Meta als Badges im Chat statt in Meta-Bar anzeigen
- Meta-Bar zeigt nur noch Status (Streaming.../Bereit)
- Chain-Infos (Model, Tokens, Dauer etc.) als Badges im Chat
- Badges bei Streaming, History-Load und Archiv-Ansicht
- buildMetaString → buildMetaBadges, neue addChainMetaBubble Funktion
2026-02-28 18:31:28 +01:00

309 lines
9.1 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;
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
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' ? '📷 ' : '📄 ') + 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>⏳ 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);
}
if (data.model) {
addChainMetaBubble(data, false);
} else if (data.chainId) {
apiGetChain(data.chainId).then(chain => {
if (chain) addChainMetaBubble(chain, false);
}).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>⏳ 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();
// Erst neue aus DB laden, dann gestreamte Elemente ersetzen
await pollHistory();
removeStreamedElements();
}
}
// ── 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">📄</span>'}
<span class="file-name">${esc(f.name)}</span>
<button class="file-remove" onclick="removePendingFile(${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">📂</span>
<span class="ref-label">${esc(label)}</span>
<button class="file-remove" onclick="removePendingRef(${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 = '';
}