diff --git a/Services/AnthropicService.Agents.cs b/Services/AnthropicService.Agents.cs index 623904b..e2da8c4 100644 --- a/Services/AnthropicService.Agents.cs +++ b/Services/AnthropicService.Agents.cs @@ -441,6 +441,14 @@ public partial class AnthropicService /// Bei Fehler Exception werfen. Wenn null, wird Standard-Executor verwendet. /// public Func> ExecuteTool { get; set; } + + /// + /// Optionaler Transkript-Sammler (Panel-Member). Wenn gesetzt, sammelt der Loop + /// die interleavte Abfolge aus Text- und Tool-Segmenten, damit der Caller sie als + /// EINE strukturierte Message persistieren kann (Reload-Wiederherstellung der + /// Tool-Boxen). Segmente: {t:"text", v:...} bzw. {t:"tool", name, input, result, error}. + /// + public List TranscriptSink { get; set; } } /// @@ -570,6 +578,8 @@ public partial class AnthropicService } if (cb.SseResponse != null) await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { index = cb.MemberIndex, text, agentName = cb.AgentName }, cb.SseCt, cb.SseGate); + + cb.TranscriptSink?.Add(new Dictionary { ["t"] = "text", ["v"] = text }); } else if (blockType == "tool_use") { @@ -628,7 +638,13 @@ public partial class AnthropicService }); if (cb.SseResponse != null) - await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { index = cb.MemberIndex, name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt, cb.SseGate); + await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { index = cb.MemberIndex, name = toolName, input = inputEl.ToString(), result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt, cb.SseGate); + + cb.TranscriptSink?.Add(new Dictionary + { + ["t"] = "tool", ["name"] = toolName, ["input"] = inputEl.ToString(), + ["result"] = TruncateToolResult(toolResult), ["error"] = isError + }); } } diff --git a/Services/AnthropicService.Panel.cs b/Services/AnthropicService.Panel.cs index 68b529b..72ba21f 100644 --- a/Services/AnthropicService.Panel.cs +++ b/Services/AnthropicService.Panel.cs @@ -164,25 +164,30 @@ public partial class AnthropicService // workerMessages oder parallele Member leckt. var memberMessages = CloneForMember(workerMessages, previousLayerBlock); string text; + List segments = null; // interleavtes Transkript (Text + Tools) für die Reload-Persistenz try { if (!string.IsNullOrEmpty(member.Provider) && _openAiService != null) { // OpenAI-kompatibler Member: tool-los (CompleteText), Proposer aus Kontext. + // Kein Tool-Transkript → unten als einzelnes Text-Segment persistiert. var systemText = ExtractSystemPromptText(member.SystemPrompt); text = await _openAiService.CompleteText(member.ModelId, systemText, memberMessages, apiToken); } else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable) { // Kein API-Key → CLI-Member, read-only (Plan-Modus: nur Read/Glob/Grep). - // Streamt LIVE (Token + Tool-Uses) über panel_member_*-Events. - text = await RunPanelMemberViaCli(response, member, memberMessages, index, sseGate, workCt, sseCt); + // Streamt LIVE (Token + Tool-Uses) über panel_member_*-Events und liefert das Transkript. + var (t, segs) = await RunPanelMemberViaCli(response, member, memberMessages, index, sseGate, workCt, sseCt); + text = t; segments = segs; } else { // Anthropic-API-Member: read-only (PlanMode) + NICHT persistieren (Caller persistiert einmal). // Live-Streaming über den Loop: pro Tool-Runde panel_member_text / panel_member_tool. + // TranscriptSink sammelt Text-/Tool-Segmente für die Reload-Persistenz. var memberUsage = new TokenUsage(); + segments = new List(); var (t, _) = await RunNonStreamingToolLoop( member.ModelId, member.SystemPrompt, new ToolPayloadOptions @@ -197,28 +202,42 @@ public partial class AnthropicService { Persist = false, SseResponse = response, SseCt = sseCt, SseGate = sseGate, - SsePrefix = "panel_member", MemberIndex = index, AgentName = name + SsePrefix = "panel_member", MemberIndex = index, AgentName = name, + TranscriptSink = segments }, member.Effort); text = t; } } catch (OperationCanceledException) { throw; } - catch (Exception ex) { text = $"[Fehler bei {name}: {ex.Message}]"; } + catch (Exception ex) { text = $"[Fehler bei {name}: {ex.Message}]"; segments = null; } text = (text ?? "").Trim(); - // Proposal als EINE Message persistieren: Tag="panel" → aus Worker-History gefiltert, - // [Panel: Name]-Prefix → Label beim History-Rendering. Iteration=index+1 → stabile Reihenfolge. - // Nur die FINALE Runde persistieren (MoA-Zwischenrunden sind live-only, überleben kein F5). - if (persist && !string.IsNullOrEmpty(text)) + // Member-Transkript als EINE strukturierte Message persistieren (Tag="panel" → aus + // Worker-History gefiltert, Iteration=index+1 → stabile Reihenfolge). Content = JSON + // {name, model, segments:[{t:"text"|"tool",...}]} → der Reload stellt Text UND Tool-Boxen + // wieder her (genau wie live). Fällt auf ein einzelnes Text-Segment zurück (OpenAI-Member, + // Fehler oder wenn nichts gestreamt wurde). Nur die FINALE Runde (MoA-Zwischenrunden sind + // live-only und überleben kein F5). + if (persist) { - await _db.SaveMessage(new MessageRecord + if (segments == null || segments.Count == 0) { - ChainId = chainId, Iteration = index + 1, - Role = "assistant", Type = "text", - Content = $"[Panel: {name}]\n{text}", Tag = "panel" - }); + segments = new List(); + if (!string.IsNullOrEmpty(text)) + segments.Add(new Dictionary { ["t"] = "text", ["v"] = text }); + } + if (segments.Count > 0) + { + var content = JsonSerializer.Serialize(new { name, model = member.ModelId, segments }); + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = index + 1, + Role = "assistant", Type = "text", + Content = content, Tag = "panel" + }); + } } await SseHelper.TryWriteSse(response, "panel_member_done", @@ -235,7 +254,7 @@ public partial class AnthropicService /// laufen durch den geteilten (parallele Member → sonst /// vermischen sich die Frames). Persistiert nichts – der Caller speichert eine Proposal-Message. /// - private async Task RunPanelMemberViaCli( + private async Task<(string text, List segments)> RunPanelMemberViaCli( HttpResponse response, SubAgentContext member, List memberMessages, @@ -244,48 +263,92 @@ public partial class AnthropicService CancellationToken workCt, CancellationToken sseCt) { - if (_cliService == null || !_cliService.IsAvailable) return ""; + if (_cliService == null || !_cliService.IsAvailable) return ("", new List()); var systemText = ExtractSystemPromptText(member.SystemPrompt); var prompt = ExtractLastUserText(memberMessages) ?? ""; + + // Bild-/Datei-Anhänge der letzten User-Message als Temp-Dateien ablegen, damit der + // CLI-Member sie per Read-Tool sehen kann (Screenshots, PDFs etc.) – exakt wie der Worker. + // Ohne das wären die Member bei bildbasierten Fragen blind → read-only Tools nutzlos. + var tempImagePaths = new List(); + var imageRefs = ExtractLastUserImageRefs(memberMessages, tempImagePaths); + if (!string.IsNullOrEmpty(imageRefs)) + prompt = imageRefs + "\n" + prompt; + var historyContext = BuildHistoryContext(memberMessages); if (!string.IsNullOrEmpty(historyContext)) prompt = $"\n{historyContext}\n\n\n\n{prompt}\n"; var fullText = new StringBuilder(); - string currentToolName = null; // ToolResult trägt keinen Namen → vom ToolStart merken - await _cliService.ChatStreamingAsync( - prompt, - async evt => - { - switch (evt.Type) + // Transkript-Segmente (Text + Tools interleaved) für die Reload-Persistenz. + var segments = new List(); + var segText = new StringBuilder(); + void FlushText() + { + if (segText.Length == 0) return; + segments.Add(new Dictionary { ["t"] = "text", ["v"] = segText.ToString() }); + segText.Clear(); + } + // Tool-Uses per ID korrelieren: Ein Member kann MEHRERE Tools in einer Assistant-Message + // aufrufen (ToolStart#1,#2,… ToolResult#1,#2,…). Ein einzelner "currentTool"-Zeiger würde + // dann Boxen vertauschen/verwaisen lassen. Außerdem ist der Tool-Input erst bei ToolEnd + // (content_block_stop) vollständig – ToolStart/ToolResult tragen ihn nicht. + var toolNames = new Dictionary(); // toolId -> CLI-Name + var toolInputs = new Dictionary(); // toolId -> Input-JSON + try + { + await _cliService.ChatStreamingAsync( + prompt, + async evt => { - case CliEventType.TextDelta: - fullText.Append(evt.Text); - await SseHelper.TryWriteSse(response, "panel_member_text", - new { index, text = evt.Text }, sseCt, sseGate); - break; - case CliEventType.ToolStart: - currentToolName = evt.ToolName; - await SseHelper.TryWriteSse(response, "panel_member_tool_use", - new { index, name = evt.ToolName }, sseCt, sseGate); - break; - case CliEventType.ToolResult: - await SseHelper.TryWriteSse(response, "panel_member_tool_end", - new { index, name = currentToolName, result = evt.ToolResultContent, isError = evt.ToolResultIsError }, sseCt, sseGate); - currentToolName = null; - break; - } - }, - modelId: member.ModelId, - systemPrompt: systemText, - workspace: member.Workspace, - hasWorkspace: !string.IsNullOrEmpty(member.Workspace), - planMode: true, - effort: member.Effort, - cancellationToken: workCt - ); - return fullText.ToString(); + switch (evt.Type) + { + case CliEventType.TextDelta: + fullText.Append(evt.Text); + segText.Append(evt.Text); + await SseHelper.TryWriteSse(response, "panel_member_text", + new { index, text = evt.Text }, sseCt, sseGate); + break; + case CliEventType.ToolStart: + FlushText(); // Text-Segment vor der Tool-Box schließen (Reihenfolge wie live) + if (!string.IsNullOrEmpty(evt.ToolId)) toolNames[evt.ToolId] = evt.ToolName; + await SseHelper.TryWriteSse(response, "panel_member_tool_use", + new { index, id = evt.ToolId, name = evt.ToolName }, sseCt, sseGate); + break; + case CliEventType.ToolEnd: + // Input streamt erst nach ToolStart ein → hier vollständig. Für ToolResult merken. + if (!string.IsNullOrEmpty(evt.ToolId)) toolInputs[evt.ToolId] = evt.ToolInput; + break; + case CliEventType.ToolResult: + var rid = evt.ToolId ?? ""; + toolNames.TryGetValue(rid, out var rName); + toolInputs.TryGetValue(rid, out var rInput); + segments.Add(new Dictionary + { + ["t"] = "tool", ["name"] = rName ?? "", ["input"] = rInput ?? "", + ["result"] = TruncateToolResult(evt.ToolResultContent), ["error"] = evt.ToolResultIsError + }); + await SseHelper.TryWriteSse(response, "panel_member_tool_end", + new { index, id = rid, name = rName, input = rInput, result = evt.ToolResultContent, isError = evt.ToolResultIsError }, sseCt, sseGate); + break; + } + }, + modelId: member.ModelId, + systemPrompt: systemText, + workspace: member.Workspace, + hasWorkspace: !string.IsNullOrEmpty(member.Workspace), + planMode: true, + effort: member.Effort, + cancellationToken: workCt + ); + } + finally + { + CleanupTempImages(tempImagePaths); + } + FlushText(); // restliches Text-Segment am Ende sichern + return (fullText.ToString(), segments); } /// Extrahiert den System-Prompt-Text aus den List<object>-Blöcken eines SubAgentContext. diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 57efb9f..da623dd 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -481,13 +481,56 @@ function _renderReviewGroup(tagGroup, archived) { } /** - * Renders a single panel member's proposal (tag="panel", content "[Panel: Name]\n...") - * as its own panel bubble – matches the live panel_member bubble. + * Renders a single panel member (tag="panel") as its own panel bubble – matches the live + * panel_member bubble. Two persisted formats: + * - NEW: JSON transcript {name, model, segments:[{t:"text",v} | {t:"tool",name,input,result,error}]} + * → restores text AND tool boxes interleaved (just like live). + * - OLD (back-compat): plaintext "[Panel: Name]\n..." → text only. */ function _renderPanelMemberBubble(msg, archived) { var bubble = document.createElement('div'); bubble.className = 'msg review-bubble panel-bubble' + (archived ? ' archived' : ''); var content = msg.content || ''; + + // ── Neues Format: JSON-Transkript (Text + Tool-Boxen) ── + var data = null; + if (content.charAt(0) === '{') { + try { data = JSON.parse(content); } catch (e) { data = null; } + } + if (data && Array.isArray(data.segments)) { + var model = (typeof prettyModelName === 'function') ? prettyModelName(data.model) : data.model; + bubble.appendChild(_createSubAgentHeader('panel', data.name, model ? { model: model } : undefined)); + var rendered = false; + for (var i = 0; i < data.segments.length; i++) { + var seg = data.segments[i]; + if (!seg) continue; + if (seg.t === 'text') { + if (!seg.v) continue; + var tdiv = document.createElement('div'); + tdiv.className = 'review-content markdown'; + tdiv.innerHTML = renderMarkdown(seg.v); + parseFluentEmojis(tdiv); + bubble.appendChild(tdiv); + rendered = true; + } else if (seg.t === 'tool') { + if (typeof isToolVisible === 'function' && !isToolVisible(seg.name)) continue; + var box = document.createElement('div'); + box.className = 'tool-box review-tool-box'; + renderToolBoxContent(box, seg.name, seg.input || null, seg.result, seg.error, null); + bubble.appendChild(box); + rendered = true; + } + } + if (!rendered) { + var empty = document.createElement('div'); + empty.className = 'review-content markdown'; + empty.innerHTML = renderMarkdown('_(keine Antwort)_'); + bubble.appendChild(empty); + } + return bubble; + } + + // ── Alt-Format (Back-Compat): "[Panel: Name]\n..." Plaintext ── var name = null; var nameMatch = content.match(/^\[Panel: ([^\]]+)\]/); if (nameMatch) name = nameMatch[1]; diff --git a/wwwroot/js/stream-ui.js b/wwwroot/js/stream-ui.js index 0f94b37..03c4396 100644 --- a/wwwroot/js/stream-ui.js +++ b/wwwroot/js/stream-ui.js @@ -247,7 +247,7 @@ function addPanelMemberBubble(data) { 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._toolBoxes = {}; // toolId -> offene Tool-Box (CLI: per ID korreliert → robust bei parallelen Tools) bubble._fullText = ''; // gesamter gestreamter Text (für Finalize-Fallback) _panelActiveLayer[data.index] = layer; // ab jetzt zeigen Events für diesen index auf diese Runde _panelBubbles[_panelKey(data.index, layer)] = bubble; @@ -259,6 +259,11 @@ function _panelClearSpinner(bubble) { if (bubble._panelSpinner) { bubble._panelSpinner.remove(); bubble._panelSpinner = null; } } +function _panelRemoveCursor(bubble) { + var c = bubble.querySelector('.cursor'); + if (c) c.remove(); +} + function appendPanelMemberText(data) { var bubble = _panelGetBubble(data.index); if (!bubble || !data.text) return; @@ -273,6 +278,12 @@ function appendPanelMemberText(data) { bubble._segTextStr += data.text; bubble._fullText += data.text; bubble._segText.innerHTML = renderMarkdown(bubble._segTextStr); + // Blinkender Cursor ans Ende des Segments (wie beim Worker-Stream) → "schreibt gerade" + var cursor = document.createElement('span'); + cursor.className = 'cursor'; + var lastBlock = bubble._segText.lastElementChild; + if (lastBlock) lastBlock.appendChild(cursor); + else bubble._segText.appendChild(cursor); scrollDown(); } @@ -280,15 +291,17 @@ function addPanelMemberToolUse(data) { var bubble = _panelGetBubble(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; } + _panelRemoveCursor(bubble); // Cursor aus dem laufenden Text-Segment entfernen + bubble._segText = null; // Text-Segment schließen → Folge-Text kommt UNTER die Tool-Box + var nm = normalizeToolName(data.name); // CLI-Name (Bash) → gott-Konvention (execute) – konsistent zur fertigen Box + if (!isToolVisible(nm)) 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.dataset.toolName = nm; + var displayName = TOOL_DISPLAY_NAMES[nm] || nm; toolDiv.innerHTML = '' + esc(displayName) + ' '; bubble.appendChild(toolDiv); - bubble._tool = toolDiv; + if (data.id) bubble._toolBoxes[data.id] = toolDiv; // per ID korreliert (mehrere Tools parallel) scrollDown(); } @@ -297,18 +310,17 @@ function finishPanelMemberTool(data) { 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); + // CLI: offene Box per ID finden; Anthropic-Loop (kombiniert, ohne ID) → neue Box anlegen + var box = (data.id && bubble._toolBoxes && bubble._toolBoxes[data.id]) ? bubble._toolBoxes[data.id] : null; + if (!box) { + if (!isToolVisible(normalizeToolName(data.name))) return; + box = document.createElement('div'); + box.className = 'tool-box review-tool-box streamed'; + bubble.appendChild(box); } + // data.name = roher Name, data.input = roher Input-JSON → renderToolBoxContent normalisiert beides + renderToolBoxContent(box, data.name, data.input || null, data.result, data.isError, null); + if (data.id && bubble._toolBoxes) delete bubble._toolBoxes[data.id]; scrollDown(); } @@ -316,6 +328,16 @@ function finalizePanelMember(data) { var bubble = _panelGetBubble(data.index); if (!bubble) return; _panelClearSpinner(bubble); + _panelRemoveCursor(bubble); + // Verwaiste Tool-Boxen (Tool ohne Result, z.B. in Plan-Mode geblockt) → Spinner entfernen, + // damit keine Box mit endlosem "⏳" stehen bleibt. + if (bubble._toolBoxes) { + for (var tid in bubble._toolBoxes) { + var sp = bubble._toolBoxes[tid].querySelector('.tool-spinner'); + if (sp) sp.remove(); + } + bubble._toolBoxes = {}; + } // 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)_';