- prettyModelName() von chat.js nach ui.js als globale Funktion verschoben - Session-Cards: Kontext-Badge mit Tooltip wenn context vorhanden - Agent-Cards: Badges für ToolChoice:any, AutoCommit, Soul, Identity, UserInfo, Memory - Agent-Cards: Skill-Badges pro aktiviertem Skill (parallel geladen) - Agent-Cards: Model-Name via prettyModelName() statt roher Model-ID - Neue CSS-Klassen: badge-feature, badge-forced-feature, badge-skill - Theme: primary/dangerous Farben angepasst - Chat: Input overflow hidden→auto
422 lines
13 KiB
JavaScript
422 lines
13 KiB
JavaScript
// ── 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 ──
|
||
|
||
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
|
||
let _abortController = null; // AbortController für laufenden Stream
|
||
|
||
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();
|
||
}
|
||
|
||
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 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;
|
||
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(messages, 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 '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();
|
||
// 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" 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;
|
||
}
|