Files
gott/wwwroot/js/chat.js
T
MelekiandClaude Opus 4.8 b6e546f618 Add workflow-status telemetry view and make panel modes on-demand
Workflow-status: a new WorkflowProgressTracker singleton taps the
suppressed dynamic-workflow subagent events (those carrying
parent_tool_use_id) into per-session telemetry without altering the
existing guard; the snapshot is serialized into the new chains.workflow_info
column on FinalizeChain (ChainId-matched so a panel member of the same
session cannot clear it) and exposed via GET /api/sessions/{id}/workflow-status
(live -> persisted -> none). The header menu gains a 'Workflow-Status' entry
with a modal that live-polls every 2s while running and self-stops on close.

Panel on-demand: Fusion/MoA is now a transient per-request multiModelMode
flag (like plan/ultracode) instead of a persisted session column. Session
settings keep only the panel members + moa_layers; the mode <select> was
removed and the composer dropdown drives the mode per request.

Docs: ARCHITECTURE.md, ARCHITECTURE_QUICK.md and README.md updated for both
features plus the already-shipped z.ai/GLM provider, GLM 5.2+ effort
passthrough, web-search-prime MCP and the ultracode toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:27:46 +02:00

752 lines
27 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));
if (d.effort) badges.push('Effort: ' + d.effort);
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;
// Multi-Modell-Panel (Fusion/MoA): mehrere Member streamen parallel LIVE
case 'panel_start':
startPanel(data);
break;
// MoA: Runden-Trenner (nur bei mehreren Runden sichtbar)
case 'panel_layer_start':
startPanelLayer(data);
break;
case 'panel_layer_done':
break;
case 'panel_member_start':
addPanelMemberBubble(data);
break;
case 'panel_member_text':
appendPanelMemberText(data);
break;
case 'panel_member_tool_use':
addPanelMemberToolUse(data);
break;
// tool_end = CLI (offene Box füllen); tool = Anthropic-Loop (kombiniert → fertige Box)
case 'panel_member_tool_end': case 'panel_member_tool':
finishPanelMemberTool(data);
break;
case 'panel_member_error':
appendPanelMemberText({ index: data.index, text: '\n\n_[Fehler: ' + (data.error || 'unbekannt') + ']_' });
break;
case 'panel_member_done':
finalizePanelMember(data);
break;
case 'panel_done':
endPanel(data);
break;
case 'auto_context':
addAutoContextBubble(data.context);
break;
case 'auto_continue':
addWarningBubble(`Auto-Continue (${data.attempt}/${data.maxAttempts}): Modell wird fortgesetzt…`, true);
break;
case 'steer_injected':
// Das Modell hat den Einwurf an dieser Stelle erhalten. Die Anzeige übernimmt
// bereits die optimistische Bubble aus sendSteer(); am Stream-Ende ersetzt
// pollHistory() sie durch die persistierte DB-Version (Type=steer).
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);
}
// Panel-Member-Tokens (Fusion/MoA) sind server-seitig bereits in die Worker-Usage gefaltet
// (seedUsage) → das done-Event trägt die Gesamtkosten, und FinalizeChain persistiert sie
// (überlebt F5). Hier nichts mehr zu addieren.
_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;
}
}
// ── Unified Modus-Menü (Aus / Plan / Ultracode / Fusion / MoA) ──
//
// Der Button (#planModeBtn) öffnet ein Dropdown-Menü (Muster wie der Emoji-Picker), aus dem
// der Betriebsmodus direkt gewählt wird. Der Button zeigt weiterhin das Symbol des aktiven
// Modus; im Menü ist der aktive Eintrag markiert.
//
// ALLE Modi (Plan / Ultracode / Fusion / MoA) sind transient: sie gelten nur für die nächste
// Anfrage, werden NICHT in der Session persistiert und beim Chat-Wechsel auf 'off' zurückgesetzt.
// Der gewählte Modus wird pro Request als Flag mitgeschickt (planMode / ultracode / multiModelMode).
// Die Panel-MITGLIEDER (PanelAgentIds) bleiben dagegen in den Session-Einstellungen persistent.
//
// _planModeActive / _ultracodeActive bleiben als Compat-Variablen (sendMessage + modals.js lesen
// sie direkt); Fusion/MoA werden direkt aus _compositeMode abgeleitet.
// Metadaten je Modus – eine Quelle für Button-Render UND Menü-Aufbau: Reihenfolge im Menü,
// Icon (Button + Menü), Label, Button-Aktiv-Klasse, Input-Placeholder, kurze Beschreibung.
const _COMPOSITE_MODES = [
{ mode: 'off', icon: 'fa-user', label: 'Normal', btnClass: null, placeholder: 'Nachricht...', desc: 'Normaler Einzel-Agent' },
{ mode: 'plan', icon: 'fa-map', label: 'Plan-Modus', btnClass: 'plan-mode-active', placeholder: 'Planen...', desc: 'Nur analysieren & planen (read-only)' },
{ mode: 'ultracode', icon: 'fa-sitemap', label: 'Ultracode', btnClass: 'ultracode-active', placeholder: 'Ultracode – als Workflow...', desc: 'Dynamischer Workflow (mehrere Subagenten)' },
{ mode: 'fusion', icon: 'fa-users', label: 'Fusion', btnClass: 'panel-mode-fusion', placeholder: 'Nachricht...', desc: 'Panel: Antworten fusionieren' },
{ mode: 'moa', icon: 'fa-clone', label: 'MoA', btnClass: 'panel-mode-moa', placeholder: 'Nachricht...', desc: 'Panel: Mixture-of-Agents' }
];
let _compositeMode = 'off'; // 'off' | 'plan' | 'ultracode' | 'fusion' | 'moa'
let _compositeHasAgents = false;
// Compat-Variablen – werden durch setCompositeMode / initCompositeMode synchron gehalten
let _planModeActive = false; // true genau wenn _compositeMode === 'plan'
let _ultracodeActive = false; // true genau wenn _compositeMode === 'ultracode' (transient, pro Request)
// Offenes Modus-Dropdown + Outside-Click-Handler (wie beim Emoji-Picker).
let _compositeMenuEl = null;
let _compositeMenuCloseHandler = null;
function _compositeMeta(mode) {
for (var i = 0; i < _COMPOSITE_MODES.length; i++) {
if (_COMPOSITE_MODES[i].mode === mode) return _COMPOSITE_MODES[i];
}
return _COMPOSITE_MODES[0];
}
function _renderCompositeBtn() {
var btn = $('#planModeBtn');
if (!btn) return;
btn.classList.remove('plan-mode-active', 'ultracode-active', 'panel-mode-fusion', 'panel-mode-moa');
var meta = _compositeMeta(_compositeMode);
var icon = btn.querySelector('i');
if (icon) icon.className = 'fa ' + meta.icon;
if (meta.btnClass) btn.classList.add(meta.btnClass);
btn.title = _compositeMode === 'off'
? 'Modus wählen (aktuell: Aus)'
: 'Modus: ' + meta.label + ' · klicken zum Wechseln';
var input = $('#input');
if (input) input.placeholder = meta.placeholder;
}
// Initialisiert den Button bei jedem Chat-Wechsel. ALLE Modi (Plan/Ultracode/Fusion/MoA) sind
// transient (pro Anfrage) → der Button startet immer auf 'off'. `hasAgents` steuert nur den Hinweis,
// ob für Fusion/MoA überhaupt Panel-Agenten konfiguriert sind.
function initCompositeMode(hasAgents) {
_compositeHasAgents = !!hasAgents;
_compositeMode = 'off';
_planModeActive = false;
_ultracodeActive = false;
_renderCompositeBtn();
}
// Setzt den Modus direkt (aus dem Menü gewählt). ALLE Modi sind transient (Request-Flags) –
// nichts wird mehr persistiert. Plan/Ultracode/Fusion/MoA wirken nur für die nächste Anfrage
// und werden beim Chat-Wechsel auf 'off' zurückgesetzt. Die Panel-Mitglieder bleiben dagegen
// persistent (in den Session-Einstellungen festgelegt).
function setCompositeMode(mode) {
if (mode === _compositeMode) return; // schon aktiv → nichts tun
if (_compositeMeta(mode).mode !== mode) return; // unbekannter Modus → ignorieren
_compositeMode = mode;
_planModeActive = (mode === 'plan');
_ultracodeActive = (mode === 'ultracode');
_renderCompositeBtn();
if (typeof showToast === 'function') {
if (mode === 'off') {
showToast('Modus: Normal', 'info');
} else if (mode === 'plan') {
showToast('Plan-Modus aktiv', 'info');
} else if (mode === 'ultracode') {
showToast('Ultracode aktiv – diese Anfrage läuft als dynamischer Workflow (mehr Tokens/Zeit)', 'info');
} else if (!_compositeHasAgents) {
showToast('Panel: ' + (mode === 'moa' ? 'MoA' : 'Fusion') + ' aktiv – aber keine Panel-Agenten konfiguriert (im Session-Bearbeiten festlegen)', 'info');
} else {
showToast('Panel: ' + (mode === 'moa' ? 'MoA' : 'Fusion') + ' aktiv', 'success');
}
}
}
// ── Modus-Dropdown-Menü (Muster wie der Emoji-Picker) ──
function _buildCompositeMenu() {
var menu = document.createElement('div');
menu.className = 'composite-menu';
_COMPOSITE_MODES.forEach(function (m) {
var active = (m.mode === _compositeMode);
var item = document.createElement('button');
item.className = 'composite-menu-item' + (active ? ' active' : '');
item.innerHTML =
'<i class="fa ' + m.icon + ' composite-menu-icon"></i>' +
'<span class="composite-menu-text">' +
'<span class="composite-menu-label">' + m.label + '</span>' +
'<span class="composite-menu-desc">' + m.desc + '</span>' +
'</span>' +
(active ? '<i class="fa fa-check composite-menu-check"></i>' : '');
item.addEventListener('click', function (e) {
e.stopPropagation();
closeCompositeMenu();
setCompositeMode(m.mode);
});
menu.appendChild(item);
});
return menu;
}
function toggleCompositeMenu(anchor) {
if (_compositeMenuEl) { closeCompositeMenu(); return; }
var menu = _buildCompositeMenu();
document.body.appendChild(menu);
_compositeMenuEl = menu;
// Positionierung: über dem Button (Input-Leiste ist unten), linksbündig zum Button.
var rect = anchor.getBoundingClientRect();
var h = menu.offsetHeight;
var w = menu.offsetWidth;
var top = rect.top - h - 6;
var left = rect.left;
if (top < 8) top = rect.bottom + 6; // kein Platz oben → unten
if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8; // rechts abgeschnitten
if (left < 8) left = 8;
menu.style.top = top + 'px';
menu.style.left = left + 'px';
_compositeMenuCloseHandler = function (e) {
if (!menu.contains(e.target) && e.target !== anchor && !anchor.contains(e.target)) closeCompositeMenu();
};
setTimeout(function () { document.addEventListener('click', _compositeMenuCloseHandler); }, 0);
}
function closeCompositeMenu() {
if (_compositeMenuEl) { _compositeMenuEl.remove(); _compositeMenuEl = null; }
if (_compositeMenuCloseHandler) {
document.removeEventListener('click', _compositeMenuCloseHandler);
_compositeMenuCloseHandler = null;
}
}
// ── 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();
// Eine Live-SSE-Antwort rendert ALLEIN. Einen evtl. noch laufenden Recovery-Poll
// (_streamingPollInterval – gestartet beim Betreten des Chats WAEHREND eine Chain
// lief) hier stoppen. Sonst rendert dessen pollHistory(true) dieselben DB-Nachrichten
// ein zweites Mal NEBEN den Live-Bubbles (die lastKnownMessageId nicht vorruecken)
// => Doppel-Nachrichten nach Eintritt in eine laufende Chain.
if (_streamingPollInterval) {
clearInterval(_streamingPollInterval);
_streamingPollInterval = null;
}
_currentToolBox = null;
_chatAbandoned = false;
_stopClickCount = 0;
startTypingIndicator();
// Während des Streams wird die Eingabe zum Steering-Einwurf (Enter wirft ein).
const _steerInputEl = $('#input');
const _prevPlaceholder = _steerInputEl ? _steerInputEl.placeholder : '';
if (_steerInputEl) {
_steerInputEl.placeholder = 'Einwurf...';
_steerInputEl.classList.add('steer-mode');
}
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;
// Regulaeres History-Polling idempotent wieder sicherstellen: Falls beim Eintritt
// in eine laufende Chain auf den Recovery-Poll umgeschaltet (und History-Polling
// gestoppt) wurde, ist es nach dem Live-Stream sonst aus. startHistoryPolling()
// hat selbst einen Guard (if (historyPollInterval) return) => kein Doppel-Intervall.
startHistoryPolling();
// Steering-Hinweis zurücksetzen (Eingabe ist wieder normales Senden)
if (_steerInputEl) {
_steerInputEl.placeholder = _prevPlaceholder;
_steerInputEl.classList.remove('steer-mode');
}
// 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() {
const input = $('#input');
if (isStreaming) {
// Steering: Während eine Chain läuft, wird die Nachricht als Einwurf in die
// laufende Arbeit eingespeist (statt wie früher hart blockiert zu werden).
const steerText = input.value.trim();
if (steerText) sendSteer(steerText);
return;
}
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>`;
}
if (_ultracodeActive) {
msgText += `\n<hint>Ultracode ist aktiv: Bearbeite diese Anfrage als dynamischen Workflow – use a workflow. Fächere die Arbeit über mehrere Subagenten parallel auf, lass Befunde adversarial gegenprüfen und synthetisiere am Ende. Nutze dafür das Workflow-Tool.</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, _ultracodeActive, _compositeMode));
}
// ── Steering (Einwurf in die laufende Chain) ──
async function sendSteer(text) {
const input = $('#input');
// Eingabe leeren + optimistische Bubble (wird am Stream-Ende durch die DB-Version ersetzt)
input.value = '';
input.dispatchEvent(new Event('input'));
const bubble = addSteerBubble(text, true);
scrollDownForce();
let res;
try {
res = await apiChatSteer(getCurrentSessionId(), text);
} catch (e) {
if (bubble) bubble.remove();
input.value = text;
input.dispatchEvent(new Event('input'));
addWarningBubble('Einwurf fehlgeschlagen: ' + (e.message || e));
return;
}
if (!res || !res.injected) {
// Race: die laufende Antwort ist gerade fertig geworden → Text zurücklegen, normal erneut senden
if (bubble) bubble.remove();
input.value = text;
input.dispatchEvent(new Event('input'));
addWarningBubble('Die laufende Antwort ist bereits beendet – bitte erneut senden.');
}
}
// ── 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, _ultracodeActive, _compositeMode));
}
// ── 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);
});