Die monolithische ui.js (1534 Zeilen) zusammen mit chat.js (612) und history.js (626) in fokussierte Module aufgeteilt: Neue Dateien aus ui.js: - ui-helpers.js (126) - DOM-Utilities, $, esc, truncate, scrollDown, Toast - markdown.js (143) - renderMarkdown, wrapKreaGalleries, LaTeX/Code-Schutz - cards.js (90) - buildSessionCard, buildAgentCard - msg-actions.js (208) - Delete, Mark, Share, Reply-Button, Timestamp - tool-ui.js (332) - Tool-Boxes, Diffs, Todo-Liste, Visibility - modals.js (190) - System-Prompt, Command-Output, Krea-Image Modals - stream-ui.js (124) - Stream-Bubble, Review-Streaming - bubbles.js (312) - User/Assistant/Error/Warning/Choice/Review Bubbles Neue Dateien aus chat.js: - reply.js (54) - Reply/Quote-Handling mit pendingReply State - files.js (110) - File-Upload, Workspace-Refs mit pendingFiles/Refs State Neue Datei aus history.js: - archive.js (173) - Archiv-Loading mit loadedArchiveCount State Verbleibend: - chat.js (448) - Stream-Events, sendMessage, handleChoiceClick, Plan-Mode - history.js (464) - History laden, Polling, Message-Rendering Kein Code verändert, nur verschoben. Alle 94 Funktionen verifiziert. Globale Variablen in die Module verschoben, wo sie primär genutzt werden.
449 lines
13 KiB
JavaScript
449 lines
13 KiB
JavaScript
// ── Helpers ──
|
||
|
||
function buildMetaBadges(d) {
|
||
const badges = [];
|
||
if (d.planMode) badges.push('📋 Plan-Modus');
|
||
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();
|
||
}
|
||
|
||
// ── Stream Event Handler (shared between sendMessage + handleChoiceClick) ──
|
||
|
||
function handleStreamEvent(eventType, data) {
|
||
switch (eventType) {
|
||
case 'text_start':
|
||
if (!_streamDiv) startStreamBubble();
|
||
break;
|
||
|
||
case 'text_delta':
|
||
appendToStream(data.text);
|
||
break;
|
||
|
||
case 'text_end':
|
||
pauseStreamBubble();
|
||
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':
|
||
pauseStreamBubble();
|
||
_currentToolBox = addToolBoxLoading('web_search');
|
||
break;
|
||
|
||
case 'web_search_end':
|
||
if (_currentToolBox) {
|
||
finishToolBox(_currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false, sources: data.sources });
|
||
}
|
||
_currentToolBox = null;
|
||
break;
|
||
|
||
case 'user_choice':
|
||
endStreamBubble();
|
||
addChoiceBubble(data.toolCallId, data.question, data.choices);
|
||
break;
|
||
|
||
case 'preparer_start':
|
||
endStreamBubble();
|
||
addReviewBubble('start', data, 'preparer');
|
||
break;
|
||
|
||
case 'preparer_text':
|
||
appendToReviewText(data.text);
|
||
break;
|
||
|
||
case 'preparer_tool_use':
|
||
addReviewToolUse(data);
|
||
break;
|
||
|
||
case 'preparer_done':
|
||
finalizeReviewBubble(data.forwarded ? 'forwarded' : 'query', data, 'preparer');
|
||
break;
|
||
|
||
case 'review_start':
|
||
endStreamBubble();
|
||
addReviewBubble('start', data);
|
||
break;
|
||
|
||
case 'review_text':
|
||
appendToReviewText(data.text);
|
||
break;
|
||
|
||
case 'review_tool_use':
|
||
addReviewToolUse(data);
|
||
break;
|
||
|
||
case 'review_feedback':
|
||
finalizeReviewBubble('feedback', data);
|
||
break;
|
||
|
||
case 'review_approved':
|
||
finalizeReviewBubble('approved', data);
|
||
break;
|
||
|
||
case 'finalizer_start':
|
||
endStreamBubble();
|
||
addReviewBubble('start', data, 'finalizer');
|
||
break;
|
||
|
||
case 'finalizer_text':
|
||
appendToReviewText(data.text);
|
||
break;
|
||
|
||
case 'finalizer_tool_use':
|
||
addReviewToolUse(data);
|
||
break;
|
||
|
||
case 'finalizer_done':
|
||
finalizeReviewBubble('done', data, 'finalizer');
|
||
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);
|
||
}
|
||
_lastDoneChainData = null;
|
||
if (data.model) {
|
||
_lastDoneChainData = data;
|
||
} else if (data.chainId) {
|
||
apiGetChain(data.chainId).then(chain => {
|
||
if (chain) _lastDoneChainData = chain;
|
||
}).catch(() => {});
|
||
}
|
||
setMeta('Bereit');
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Plan Mode ──
|
||
|
||
let _planModeActive = false;
|
||
|
||
function togglePlanMode() {
|
||
_planModeActive = !_planModeActive;
|
||
var btn = $('#planModeBtn');
|
||
if (btn) {
|
||
btn.classList.toggle('plan-mode-active', _planModeActive);
|
||
btn.title = _planModeActive ? 'Plan-Modus deaktivieren' : 'Plan-Modus aktivieren';
|
||
}
|
||
var input = $('#input');
|
||
if (input) {
|
||
input.placeholder = _planModeActive ? 'Plannen...' : 'Nachricht...';
|
||
}
|
||
}
|
||
|
||
// ── Chat State ──
|
||
|
||
let isStreaming = false;
|
||
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
|
||
let _currentToolBox = null; // Aktuell laufende Tool-Box (für Stream-Events)
|
||
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();
|
||
}
|
||
// Backend explizit stoppen (kills laufende Tools/API-Calls)
|
||
var sid = getCurrentSessionId();
|
||
if (sid) apiStopChat(sid).catch(function() {});
|
||
}
|
||
|
||
// ── 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 = '';
|
||
_currentToolBox = null;
|
||
|
||
_chatAbandoned = false;
|
||
|
||
try {
|
||
const resp = await apiChat(content, getCurrentSessionId(), _abortController.signal, _planModeActive);
|
||
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; }
|
||
|
||
handleStreamEvent(eventType, data);
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Choice Click Handler ──
|
||
|
||
async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
|
||
// Buttons deaktivieren und gewählte Option markieren
|
||
const buttons = bubbleDiv.querySelectorAll('.choice-btn');
|
||
buttons.forEach(btn => {
|
||
btn.disabled = true;
|
||
if (btn.textContent === choice) {
|
||
btn.classList.add('choice-selected');
|
||
} else {
|
||
btn.classList.add('choice-unselected');
|
||
}
|
||
});
|
||
bubbleDiv.classList.add('choice-answered');
|
||
|
||
// Stream starten mit Choice-Response
|
||
const sid = getCurrentSessionId();
|
||
const sendBtn = $('#send');
|
||
const stopBtn = $('#stop');
|
||
sendBtn.disabled = true;
|
||
sendBtn.hidden = true;
|
||
stopBtn.hidden = false;
|
||
isStreaming = true;
|
||
_abortController = new AbortController();
|
||
_currentToolBox = null;
|
||
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
|
||
_chatAbandoned = false;
|
||
|
||
try {
|
||
const resp = await apiChatChoice(sid, toolCallId, choice, _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; }
|
||
handleStreamEvent(eventType, data);
|
||
eventType = null;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (_chatAbandoned) return;
|
||
endStreamBubble();
|
||
if (e.name === 'AbortError') {
|
||
setMeta('Abgebrochen');
|
||
await new Promise(r => setTimeout(r, 500));
|
||
} else {
|
||
try {
|
||
const status = await apiIsStreaming(sid);
|
||
if (status.streaming) {
|
||
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming... (Verbindung wiederhergestellt)</span>');
|
||
startStreamingPoll(sid, sendBtn, status.chainId);
|
||
return;
|
||
}
|
||
} catch (_) {}
|
||
addErrorBubble('Netzwerk-Fehler: ' + e.message);
|
||
}
|
||
} finally {
|
||
if (_chatAbandoned) return;
|
||
if (!isStreaming || !_streamingPollInterval) {
|
||
sendBtn.disabled = false;
|
||
sendBtn.hidden = false;
|
||
stopBtn.hidden = true;
|
||
isStreaming = false;
|
||
_abortController = null;
|
||
}
|
||
endStreamBubble();
|
||
removeStreamedElements();
|
||
await pollHistory();
|
||
if (_lastDoneChainData) {
|
||
addChainMetaBubble(_lastDoneChainData, false);
|
||
_lastDoneChainData = null;
|
||
}
|
||
}
|
||
}
|