Stream panel member responses live with a parallel SSE write-lock

Panel members now stream token-by-token and show tool uses in real time
(like the session agent) instead of delivering text only at completion.
The key constraint is byte-level safety: N members flushing SSE frames
concurrently on the shared HttpResponse would interleave and corrupt the
stream, so a SemaphoreSlim gate serializes the writes (not the model work).

- SseHelper.TryWriteSse takes an optional write-lock (default null, backward
  compatible)
- NonStreamingLoopCallbacks carries SseGate + MemberIndex; _text/_tool/_error
  events are tagged with the member index (review/finalizer stay at -1, no gate)
- CLI members forward TextDelta/ToolStart/ToolResult live instead of
  discarding everything but the final text
- Anthropic-API members stream per tool-round via the loop callbacks; OpenAI
  members fall back to collected text at panel_member_done
- Frontend demuxes by member index into per-bubble interleaved text/tool
  rendering

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-18 13:18:13 +02:00
1 parent 268f11fa6d
commit f21ed9ff9f
5 files changed
+183 -34

No files matched your search

+81 -10
View File
@@ -206,9 +206,10 @@ function finalizeReviewBubble(type, data, agentType) {
scrollDown();
}
// ── Panel Bubbles (Fusion: mehrere Member parallel, non-streaming) ──
// Member liefern ihren Text gesammelt bei panel_member_done → pro Member eine eigene
// Bubble (keyed by index), kein Konflikt mit dem Single-_reviewBubble-State.
// ── Panel Bubbles (Fusion: mehrere Member streamen parallel LIVE) ──
// Pro Member eine eigene Bubble (keyed by index). Innerhalb der Bubble werden Text
// und Tool-Boxen interleaved gerendert (wie beim Session-Agent), sodass parallele
// Member sich NICHT miteinander vermischen – das Demuxing macht der index der Events.
let _panelBubbles = {};
@@ -222,21 +223,91 @@ function addPanelMemberBubble(data) {
bubble.className = 'msg review-bubble panel-bubble streamed';
var model = (typeof prettyModelName === 'function') ? prettyModelName(data.model) : data.model;
bubble.appendChild(_createSubAgentHeader('panel', data.name, { model: model }));
var content = document.createElement('div');
content.className = 'review-content markdown';
content.innerHTML = '<span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span> denkt nach…';
bubble.appendChild(content);
bubble._panelContent = content;
var spinner = document.createElement('div');
spinner.className = 'review-content markdown';
spinner.innerHTML = '<span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span> denkt nach…';
bubble.appendChild(spinner);
bubble._panelSpinner = spinner; // wird beim ersten Text/Tool entfernt
bubble._segText = null; // aktuell offenes Text-Segment (oder null)
bubble._segTextStr = ''; // dessen akkumulierter Text
bubble._tool = null; // aktuell offene Tool-Box (CLI: Spinner→Füllen)
bubble._fullText = ''; // gesamter gestreamter Text (für Finalize-Fallback)
_panelBubbles[data.index] = bubble;
chatEl().appendChild(bubble);
scrollDown();
}
function _panelClearSpinner(bubble) {
if (bubble._panelSpinner) { bubble._panelSpinner.remove(); bubble._panelSpinner = null; }
}
function appendPanelMemberText(data) {
var bubble = _panelBubbles[data.index];
if (!bubble || !data.text) return;
_panelClearSpinner(bubble);
// Neues Text-Segment beginnen, wenn keins offen ist (z.B. direkt nach einer Tool-Box)
if (!bubble._segText) {
bubble._segText = document.createElement('div');
bubble._segText.className = 'review-content markdown';
bubble._segTextStr = '';
bubble.appendChild(bubble._segText);
}
bubble._segTextStr += data.text;
bubble._fullText += data.text;
bubble._segText.innerHTML = renderMarkdown(bubble._segTextStr);
scrollDown();
}
function addPanelMemberToolUse(data) {
var bubble = _panelBubbles[data.index];
if (!bubble) return;
_panelClearSpinner(bubble);
bubble._segText = null; // Text-Segment schließen → Folge-Text kommt UNTER die Tool-Box
if (!isToolVisible(data.name)) { bubble._tool = null; return; }
var toolDiv = document.createElement('div');
toolDiv.className = 'tool-box review-tool-box streamed';
toolDiv.dataset.toolName = data.name;
var displayName = TOOL_DISPLAY_NAMES[data.name] || data.name;
toolDiv.innerHTML = '<span class="tool-name">' + esc(displayName) + '</span> <span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span>';
bubble.appendChild(toolDiv);
bubble._tool = toolDiv;
scrollDown();
}
function finishPanelMemberTool(data) {
var bubble = _panelBubbles[data.index];
if (!bubble) return;
_panelClearSpinner(bubble);
bubble._segText = null; // nach Tool-Result neuen Text-Block beginnen
if (bubble._tool) {
// CLI: offene Box (mit Spinner) füllen
renderToolBoxContent(bubble._tool, data.name, null, data.result, data.isError, null);
bubble._tool = null;
} else {
// Anthropic-Loop (kombiniertes panel_member_tool-Event): fertige Box direkt anlegen
if (!isToolVisible(data.name)) return;
var toolDiv = document.createElement('div');
toolDiv.className = 'tool-box review-tool-box streamed';
bubble.appendChild(toolDiv);
renderToolBoxContent(toolDiv, data.name, null, data.result, data.isError, null);
}
scrollDown();
}
function finalizePanelMember(data) {
var bubble = _panelBubbles[data.index];
if (!bubble) return;
var text = (data.text && data.text.trim()) ? data.text : '_(keine Antwort)_';
bubble._panelContent.innerHTML = renderMarkdown(text);
_panelClearSpinner(bubble);
// Wenn LIVE nichts gestreamt wurde (z.B. OpenAI-Member = one-shot): finalen Text rendern.
if (!bubble._fullText || !bubble._fullText.trim()) {
var text = (data.text && data.text.trim()) ? data.text : '_(keine Antwort)_';
if (!bubble._segText) {
bubble._segText = document.createElement('div');
bubble._segText.className = 'review-content markdown';
bubble.appendChild(bubble._segText);
}
bubble._segText.innerHTML = renderMarkdown(text);
}
delete _panelBubbles[data.index];
scrollDown();
}