The composite mode control (#planModeBtn) now opens a dropdown menu instead of cycling, following the emoji-picker pattern: each mode is an entry with icon + label + description, the active one marked with a check. Direct jumps between modes are now possible, so selecting plan/ultracode resets the persisted panel mode to avoid running both the panel and the CLI worker. Unified the active button states. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
779 lines
28 KiB
JavaScript
779 lines
28 KiB
JavaScript
// ── 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.
|
||
//
|
||
// Plan-Modus und Ultracode sind transient (pro Request, nicht in der Session persistiert) und
|
||
// laufen ohne Panel → beim Wählen wird multiModelMode der Session auf 'off' gesetzt, damit kein
|
||
// zuvor gewähltes Fusion/MoA-Panel nebenherläuft (das Menü erlaubt direkte Sprünge, anders als
|
||
// der frühere Zyklus, der immer durch 'off' lief). Fusion/MoA werden in der Session persistiert
|
||
// und server-seitig aus ctx.MultiModelMode gelesen.
|
||
//
|
||
// _planModeActive / _ultracodeActive bleiben als Compat-Variablen (sendMessage + modals.js lesen
|
||
// sie direkt).
|
||
|
||
// 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-ban', label: 'Aus', 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;
|
||
|
||
// Serialisierte Modus-Persistenz. Schnell hintereinander gewählte Modi feuern sonst ÜBERLAPPENDE
|
||
// PATCHes, deren Commit-Reihenfolge nicht garantiert ist → ein Zwischenwert kann den letzten
|
||
// überschreiben. Und ein direkt danach gesendeter Chat-Request lädt die Session, BEVOR der Modus
|
||
// committed ist → das Backend liest den alten Modus. Die Kette garantiert (a) Reihenfolge (letzte
|
||
// Wahl gewinnt) und (b) dass sendMessage das offene PATCH abwarten kann. Sie rejected nie (Fehler
|
||
// werden hier geschluckt).
|
||
let _modePersist = Promise.resolve();
|
||
|
||
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 aus der geladenen Session (bei jedem Chat-Wechsel aufrufen).
|
||
// Plan-Modus wird immer auf 'off' zurückgesetzt (transient).
|
||
function initCompositeMode(panelMode, hasAgents) {
|
||
_compositeHasAgents = !!hasAgents;
|
||
_compositeMode = (panelMode === 'fusion' || panelMode === 'moa') ? panelMode : 'off';
|
||
_planModeActive = false;
|
||
_ultracodeActive = false;
|
||
_renderCompositeBtn();
|
||
}
|
||
|
||
// Setzt den Modus direkt (aus dem Menü gewählt).
|
||
// Persistiert das EFFEKTIVE Panel der Session (off/fusion/moa): für plan/ultracode also 'off',
|
||
// damit kein zuvor gewähltes Panel nebenherläuft. Plan/Ultracode selbst sind transient (Request-Flags).
|
||
async 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();
|
||
|
||
// Effektiver Panel-Modus: nur Fusion/MoA aktivieren ein Panel, alles andere 'off'.
|
||
// Auf die serialisierte Kette legen, damit aufeinanderfolgende Wahlen in Reihenfolge committen
|
||
// (letzte gewinnt) und sendMessage das offene PATCH abwarten kann.
|
||
var panelMode = (mode === 'fusion' || mode === 'moa') ? mode : 'off';
|
||
var sid = getCurrentSessionId();
|
||
if (sid) {
|
||
_modePersist = _modePersist
|
||
.then(function () { return apiUpdateSession(sid, { multiModelMode: panelMode }); })
|
||
.catch(function (e) {
|
||
console.error('Modus speichern fehlgeschlagen:', e);
|
||
if (typeof showToast === 'function') showToast('Modus konnte nicht gespeichert werden', 'error');
|
||
});
|
||
}
|
||
|
||
if (typeof showToast === 'function') {
|
||
if (mode === 'off') {
|
||
showToast('Modus: Aus', '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'));
|
||
|
||
// Ein evtl. noch offenes Modus-PATCH (Fusion/MoA/Aus aus dem Toggle) MUSS committed sein,
|
||
// bevor der Chat-Request die Session lädt – sonst läuft die Chain mit dem alten Modus
|
||
// (z.B. MoA → nur 1 Runde, weil das Backend noch 'fusion' liest).
|
||
await _modePersist;
|
||
|
||
await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive, _ultracodeActive));
|
||
}
|
||
|
||
// ── 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 _modePersist; // offenes Modus-PATCH abwarten (s. sendMessage)
|
||
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);
|
||
});
|