Files
gott/wwwroot/js/chat.js
T
Meleki 575e5c1d44 feat: Ältere Nachrichten nachladen per Button (500er Batches)
- Backend: beforeId-Parameter + GetActiveMessagesBeforeId + GetActiveMessageCountBeforeId
- Frontend: Button 'X ältere Nachrichten laden' statt Info-Text
- Archiv-Button erscheint erst wenn alle älteren Nachrichten geladen
- Scroll-Position bleibt beim Nachladen erhalten
- Fix: wwwroot Quell-Dateien mit publish synchronisiert
2026-03-01 16:32:08 +01:00

418 lines
13 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 _prettyModelName(model) {
if (!model) return '?';
// claude-sonnet-4-20250514 → Sonnet 4
// claude-opus-4-6 → Opus 4.6
// claude-haiku-3-5 → Haiku 3.5
const m = model.match(/claude-(\w+)-(\d+)(?:-(\d+))?/);
if (m) {
const family = m[1].charAt(0).toUpperCase() + m[1].slice(1);
const major = m[2];
const minor = m[3];
// minor > 20000000 ist ein Datums-Suffix, kein Minor-Version
if (minor && minor.length <= 2) return family + ' ' + major + '.' + minor;
return family + ' ' + major;
}
return 'Unbekannt';
}
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 ──
const messages = [];
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
async function clearChat() {
const archive = await apiArchive(getCurrentSessionId());
if (!archive) return;
messages.length = 0;
chatEl().innerHTML = '';
lastKnownMessageId = 0;
loadedArchiveCount = 0;
_oldestLoadedMessageId = 0;
_hasMoreOlderMessages = false;
clearReply();
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;
// 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 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') + (msgText ? '\n' + msgText : '');
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;
_chatAbandoned = false;
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 || _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 '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) {
// Bei Navigation nichts tun – der neue Page-Init übernimmt
if (_chatAbandoned) return;
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 {
// Bei Navigation nichts aufräumen – der neue Page-Init übernimmt
if (_chatAbandoned) return;
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 = '';
}
// ── 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;
}