Files
gott/wwwroot/js/chat.js
T
MelekiandClaude Opus 4.6 0f2f026baa feat(openai): Auto-Continue bei abgebrochenen Action-Ankündigungen + MiniMax-Sampling
MiniMax-M2.7 stoppt häufig nach Action-Ankündigung ohne Tool-Call (z.B. "Lass mich X machen:" → stop).
Auto-Continue erkennt das per Heuristik und injiziert automatisch "Continue." als User-Message (max 3×).
MiniMax-Sampling: temperature=1.0, top_p=0.95 (offizielle Empfehlung für agentic Performance).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 11:48:33 +02:00

496 lines
15 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 = [];
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 div = _createChainMetaDiv(d, streamed ? 'streamed' : null);
chatEl().appendChild(div);
scrollDown();
}
// ── Stream Event Handler (shared between sendMessage + handleChoiceClick) ──
function handleStreamEvent(eventType, data) {
switch (eventType) {
case 'thinking_start':
startThinkingBubble();
break;
case 'thinking_delta':
appendToThinking(data.text);
break;
case 'thinking_end':
endThinkingBubble();
break;
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);
_lastFinishedToolBox = _currentToolBox;
}
_currentToolBox = null;
break;
case 'tool_result':
if (_lastFinishedToolBox && data.result) {
appendToolResult(_lastFinishedToolBox, data.result, data.isError);
}
_lastFinishedToolBox = 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, true, data.viaCli);
break;
// Preparer / Review / Finalizer events (shared pattern)
case 'preparer_start': case 'review_start': case 'finalizer_start':
endStreamBubble();
addReviewBubble('start', data, eventType.split('_')[0] === 'review' ? undefined : eventType.split('_')[0]);
break;
case 'preparer_text': case 'review_text': case 'finalizer_text':
appendToReviewText(data.text);
break;
case 'preparer_tool_use': case 'review_tool_use': case 'finalizer_tool_use':
addReviewToolUse(data);
break;
case 'preparer_tool_end': case 'review_tool_end': case 'finalizer_tool_end':
finishReviewToolBox(data);
break;
case 'preparer_done':
finalizeReviewBubble(data.forwarded ? 'forwarded' : 'query', data, 'preparer');
break;
case 'review_feedback':
finalizeReviewBubble('feedback', data);
break;
case 'review_approved':
finalizeReviewBubble('approved', data);
break;
case 'finalizer_done':
finalizeReviewBubble('done', data, 'finalizer');
break;
case 'auto_context':
addAutoContextBubble(data.context);
break;
case 'auto_continue':
addWarningBubble(`Auto-Continue (${data.attempt}/${data.maxAttempts}): Modell wird fortgesetzt…`, true);
break;
case 'warning':
case 'info':
addWarningBubble(data.message, true);
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) {
// Fallback: Chain-Daten vom Server nachladen und Badge direkt rendern
// (async – _lastDoneChainData wäre im finally schon gelesen)
apiGetChain(data.chainId).then(chain => {
if (chain) addChainMetaBubble(chain, false);
}).catch(() => {});
}
setMeta(data.stopReason === 'stopped' ? 'Gestoppt' : '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 _lastFinishedToolBox = null; // Letzte fertige Tool-Box (für tool_result Zuordnung)
let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft
let _abortController = null; // AbortController für laufenden Stream
// ── Typing Indicator ──
let _currentAgentName = null;
let _typingInterval = null;
function setCurrentAgentName(name) {
_currentAgentName = name;
}
function _randomTypingPhrase() {
return TYPING_PHRASES[Math.floor(Math.random() * TYPING_PHRASES.length)];
}
function _buildTypingHtml(phrase) {
var name = _currentAgentName || 'Agent';
return '<span><i class="fa fa-hourglass-half"></i> ' + esc(name) + ' ' + esc(phrase) + '...</span>';
}
function startTypingIndicator() {
setMetaHtml(_buildTypingHtml(_randomTypingPhrase()));
_typingInterval = setInterval(function() {
setMetaHtml(_buildTypingHtml(_randomTypingPhrase()));
}, 3000);
}
function stopTypingIndicator() {
if (_typingInterval) {
clearInterval(_typingInterval);
_typingInterval = null;
}
}
async function clearChat() {
const archive = await apiArchive(getCurrentSessionId());
if (!archive) return;
chatEl().innerHTML = '';
lastKnownMessageId = 0;
loadedArchiveCount = 0;
_oldestLoadedSortKey = 0;
_hasMoreOlderMessages = false;
_autoScroll = true;
clearReply();
setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`);
showArchiveLoadButton();
}
let _stopClickCount = 0;
function stopStreaming() {
_stopClickCount++;
if (_stopClickCount >= 2) {
// Hard-Stop: Sofortiger Abbruch (Doppel-Klick)
var btn = $('#stop');
if (btn) {
btn.classList.add('stop-hard');
btn.title = 'Hard-Stop gesendet!';
}
// SSE-Stream clientseitig sofort abbrechen
if (_abortController) _abortController.abort();
}
// Backend explizit stoppen (graceful beim 1. Klick, hard beim 2.)
var sid = getCurrentSessionId();
if (sid) apiStopChat(sid).catch(function() {});
}
// ── SSE Stream Runner (shared between sendMessage + handleChoiceClick) ──
async function _runStream(fetchPromise) {
const sendBtn = $('#send');
const stopBtn = $('#stop');
const sid = getCurrentSessionId();
sendBtn.disabled = true;
sendBtn.hidden = true;
stopBtn.hidden = false;
isStreaming = true;
_abortController = new AbortController();
_currentToolBox = null;
_chatAbandoned = false;
_stopClickCount = 0;
startTypingIndicator();
try {
const resp = await fetchPromise(_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('Gestoppt');
} else {
try {
const status = await apiIsStreaming(sid);
if (status.streaming) {
startTypingIndicator();
startStreamingPoll(sid, sendBtn, status.chainId);
return;
}
} catch (_) {}
addErrorBubble('Netzwerk-Fehler: ' + e.message);
}
} finally {
if (_chatAbandoned) return;
stopTypingIndicator();
if (!isStreaming || !_streamingPollInterval) {
sendBtn.disabled = false;
sendBtn.hidden = false;
stopBtn.hidden = true;
stopBtn.classList.remove('stop-hard');
stopBtn.title = 'Antwort abbrechen';
isStreaming = false;
_abortController = null;
// Meta zurücksetzen falls kein done-Event kam (z.B. nach Stop)
var metaEl = $('#meta');
if (metaEl && (metaEl.textContent.includes('Streaming') || metaEl.textContent.includes('...'))) {
setMeta(_stopClickCount > 0 ? 'Gestoppt' : 'Bereit');
}
}
endStreamBubble();
removeStreamedElements();
await pollHistory();
if (_lastDoneChainData) {
addChainMetaBubble(_lastDoneChainData, false);
_lastDoneChainData = null;
}
}
}
// ── Send Message ──
async function sendMessage() {
if (isStreaming) return;
const input = $('#input');
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 = formatTimestamp(pendingReply.timestamp) || 'Jetzt';
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();
}
if (_planModeActive) {
msgText += `\n<hint>Plan-Modus ist aktiv. KEINE Dateien ändern, KEINE Shell-Befehle (außer read-only), KEINE Commits. Nur analysieren, Rückfragen stellen und Plan erstellen.</hint>`;
}
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);
scrollDownForce(); // Beim Senden immer nach unten
input.value = '';
input.dispatchEvent(new Event('input'));
await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive));
}
// ── Direct Message (für Inline-Choice-Buttons) ──
async function sendMessageDirect(text) {
if (isStreaming || !text) return;
addUserBubble(text, true);
scrollDownForce();
await _runStream((signal) => apiChat(text, getCurrentSessionId(), signal, _planModeActive));
}
// ── Choice Click Handler ──
async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
// Buttons deaktivieren und gewählte Option markieren
const buttons = bubbleDiv.querySelectorAll('.choice-btn');
var matchedButton = false;
buttons.forEach(btn => {
btn.disabled = true;
if (btn.textContent === choice) {
btn.classList.add('choice-selected');
matchedButton = true;
} else {
btn.classList.add('choice-unselected');
}
});
// Freitext-Antwort: Keine der Buttons matched → Antwort als Text anzeigen
if (!matchedButton) {
var customDiv = bubbleDiv.querySelector('.choice-custom');
if (customDiv) {
customDiv.innerHTML = '';
customDiv.classList.add('has-answer');
var answerSpan = document.createElement('span');
answerSpan.className = 'choice-custom-answer';
answerSpan.textContent = '↳ ' + choice;
customDiv.appendChild(answerSpan);
}
}
bubbleDiv.classList.add('choice-answered');
// CLI-Modus: Antwort über internen Endpoint an MCP-Server weiterleiten (CLI läuft weiter)
if (bubbleDiv.dataset.viaCli === 'true') {
try {
await apiChoiceRespond(getCurrentSessionId(), choice);
} catch (e) {
console.error('Choice respond failed:', e);
}
return;
}
// API-Modus: Normaler Chat-Flow (neue Chain)
await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive));
}
// ── Inline Choice Blocks (Fallback aus Markdown-Rendering) ──
// Event-Delegation für .inline-choice-btn Buttons die von renderMarkdown() erzeugt wurden
document.addEventListener('click', function(e) {
var btn = e.target.closest('.inline-choice-btn');
if (!btn) return;
var block = btn.closest('.inline-choice-block');
if (!block || block.classList.contains('choice-answered')) return;
var choice = btn.dataset.choice;
// Buttons deaktivieren + markieren
block.querySelectorAll('.inline-choice-btn').forEach(function(b) {
b.disabled = true;
b.classList.add(b === btn ? 'choice-selected' : 'choice-unselected');
});
block.classList.add('choice-answered');
// Inline-Choice als normale User-Nachricht senden (kein tool_use in DB vorhanden)
sendMessageDirect(choice);
});