using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Gott.Models; using Microsoft.AspNetCore.Http; namespace Gott.Services; public partial class AnthropicService { // ── Multi-Modell-Panel (Fusion) ── // // Mehrere Panel-Agenten ("Proposer") beantworten dieselbe Anfrage PARALLEL und // non-streaming. Anschließend werden ihre Antworten dem Worker-Agent als // -Block vorgelegt, der daraus EINE Gesamtantwort synthetisiert // und über die unveränderte Streaming-Pipeline ausgibt. // // Member laufen READ-ONLY (Anthropic: PlanMode-Filter, CLI: --permission-mode plan) // bzw. tool-los (OpenAI), damit parallele Member keine Dateien gleichzeitig schreiben. // Member-Arbeit wird NICHT über den Loop persistiert (Persist=false) – stattdessen // wird pro Member genau EINE Proposal-Message mit Tag="panel" + [Panel: Name]-Prefix // gespeichert (aus der Worker-History gefiltert, überlebt aber F5 als eigene Bubble). private class PanelMemberResult { public int Index { get; set; } public string Name { get; set; } public string Model { get; set; } public string Text { get; set; } } /// /// Führt das Panel aus (Fusion): alle Member parallel, sammelt ihre Antworten, /// persistiert sie (Tag="panel") und merged den Synthese-Block in die letzte /// User-Message von ein. Danach streamt der Worker /// als Synthesizer ganz normal weiter. Gibt die Anzahl beigetragener Member zurück /// (0 = nichts injiziert → Worker antwortet regulär). /// public async Task RunPanel( HttpResponse response, List panel, List workerMessages, string chainId, string sessionId, string apiToken, CancellationToken workCt, CancellationToken sseCt) { if (panel == null || panel.Count == 0) return 0; // Write-Lock: alle Member streamen LIVE parallel auf DIESELBE HttpResponse → // ohne Serialisierung würden sich ihre SSE-Frames auf Byte-Ebene vermischen. // Serialisiert nur die µs-Byte-Flushes, nicht die parallele Modell-Arbeit. using var sseGate = new SemaphoreSlim(1, 1); await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode = "fusion" }, sseCt, sseGate); // Concurrency begrenzen: bei CLI-Membern sind das parallele claude-Children. using var sem = new SemaphoreSlim(Math.Min(3, panel.Count)); var tasks = new List>(); for (int i = 0; i < panel.Count; i++) { var member = panel[i]; var index = i; tasks.Add(Task.Run(async () => { await sem.WaitAsync(workCt); try { return await RunPanelMember(response, member, workerMessages, index, chainId, sessionId, apiToken, sseGate, workCt, sseCt); } finally { sem.Release(); } }, workCt)); } var results = await Task.WhenAll(tasks); var ordered = results .Where(r => r != null && !string.IsNullOrWhiteSpace(r.Text)) .OrderBy(r => r.Index) .ToList(); if (ordered.Count == 0) { await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt, sseGate); return 0; } // Synthese-Block bauen und in die letzte User-Message einmergen. var sb = new StringBuilder(); sb.AppendLine(); sb.AppendLine("---"); sb.AppendLine("[Multi-Modell-Panel] Mehrere unabhängige KI-Agenten haben parallel auf die obige Anfrage geantwortet. " + "Deine Aufgabe: Synthetisiere daraus EINE optimale Gesamtantwort für den User. Nimm die stärksten Argumente, " + "korrektesten Fakten und besten Ideen aus allen Beiträgen, korrigiere erkennbare Fehler und vermeide bloßes " + "Aneinanderreihen. Widersprechen sich Beiträge, wäge ab und entscheide begründet. Antworte direkt und natürlich " + "dem User (du musst das Panel nicht erwähnen). Du kannst deine eigenen Tools nutzen, um zu prüfen oder die Aufgabe abzuschließen."); sb.AppendLine(); sb.AppendLine(""); foreach (var r in ordered) { sb.AppendLine(); sb.AppendLine($"### Antwort von {r.Name} ({r.Model}):"); sb.AppendLine(r.Text.Trim()); } sb.AppendLine(""); MergePanelIntoLastUserText(workerMessages, sb.ToString()); await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = ordered.Count }, sseCt, sseGate); return ordered.Count; } /// /// Führt einen einzelnen Panel-Member aus (Provider-Weiche, read-only/tool-los), /// persistiert seine Proposal-Message (Tag="panel") und sendet panel_member_* SSE-Events. /// Fängt Member-Fehler ab (ein fehlerhafter Member darf das Panel nicht killen). /// private async Task RunPanelMember( HttpResponse response, SubAgentContext member, List workerMessages, int index, string chainId, string sessionId, string apiToken, SemaphoreSlim sseGate, CancellationToken workCt, CancellationToken sseCt) { var name = member.Agent?.Name ?? $"Panel-Agent {index + 1}"; await SseHelper.TryWriteSse(response, "panel_member_start", new { index, name, model = member.ModelId }, sseCt, sseGate); var memberMessages = new List(workerMessages); // eigene Kopie pro Member string text; try { if (!string.IsNullOrEmpty(member.Provider) && _openAiService != null) { // OpenAI-kompatibler Member: tool-los (CompleteText), Proposer aus Kontext. 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); } 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. var memberUsage = new TokenUsage(); var (t, _) = await RunNonStreamingToolLoop( member.ModelId, member.SystemPrompt, new ToolPayloadOptions { Workspace = member.Workspace, AgentId = member.AgentId, UseMemory = member.UseMemory, WebSearchVersion = member.WebSearchVersion, ToolChoice = "auto", PlanMode = true }, memberMessages, chainId, sessionId, apiToken, memberUsage, 0, 12, workCt, new NonStreamingLoopCallbacks { Persist = false, SseResponse = response, SseCt = sseCt, SseGate = sseGate, SsePrefix = "panel_member", MemberIndex = index, AgentName = name }, member.Effort); text = t; } } catch (OperationCanceledException) { throw; } catch (Exception ex) { text = $"[Fehler bei {name}: {ex.Message}]"; } 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. if (!string.IsNullOrEmpty(text)) { await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = index + 1, Role = "assistant", Type = "text", Content = $"[Panel: {name}]\n{text}", Tag = "panel" }); } await SseHelper.TryWriteSse(response, "panel_member_done", new { index, name, model = member.ModelId, text }, sseCt, sseGate); return new PanelMemberResult { Index = index, Name = name, Model = member.ModelId, Text = text }; } /// /// Führt einen Panel-Member über die Claude CLI aus (read-only via Plan-Modus) und /// streamt seine Arbeit LIVE: Text-Deltas (panel_member_text), Tool-Start /// (panel_member_tool_use) und Tool-Result (panel_member_tool_end) – jeweils mit /// Member-Index, damit das Frontend sie der richtigen Bubble zuordnet. Die SSE-Writes /// laufen durch den geteilten (parallele Member → sonst /// vermischen sich die Frames). Persistiert nichts – der Caller speichert eine Proposal-Message. /// private async Task RunPanelMemberViaCli( HttpResponse response, SubAgentContext member, List memberMessages, int index, SemaphoreSlim sseGate, CancellationToken workCt, CancellationToken sseCt) { if (_cliService == null || !_cliService.IsAvailable) return ""; var systemText = ExtractSystemPromptText(member.SystemPrompt); var prompt = ExtractLastUserText(memberMessages) ?? ""; 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) { 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(); } /// Extrahiert den System-Prompt-Text aus den List<object>-Blöcken eines SubAgentContext. private static string ExtractSystemPromptText(List systemPrompt) { if (systemPrompt == null) return ""; var parts = new List(); foreach (var block in systemPrompt) { var json = JsonSerializer.Serialize(block); var el = JsonSerializer.Deserialize(json); if (el.TryGetProperty("text", out var t)) parts.Add(t.GetString() ?? ""); } return string.Join("\n\n", parts); } /// /// Hängt den Panel-Synthese-Block an den BESTEHENDEN Text-Block der letzten User-Message an. /// Wichtig: kein zweiter Text-Block – sonst sieht der CLI-Worker (ExtractLastUserText liefert /// nur den ersten Text-Block) die Synthese-Anweisung nicht. So funktioniert es für API, CLI und OpenAI. /// private static void MergePanelIntoLastUserText(List messages, string panelBlock) { for (int i = messages.Count - 1; i >= 0; i--) { if (messages[i] is Dictionary d && d.TryGetValue("role", out var r) && (r as string) == "user" && d.TryGetValue("content", out var c) && c is List contentList) { for (int j = 0; j < contentList.Count; j++) { var json = JsonSerializer.Serialize(contentList[j]); var el = JsonSerializer.Deserialize(json); if (el.TryGetProperty("type", out var t) && t.GetString() == "text") { var orig = el.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : ""; contentList[j] = new { type = "text", text = orig + "\n\n" + panelBlock }; return; } } // Kein Text-Block vorhanden → einen anhängen. contentList.Add(new { type = "text", text = panelBlock }); return; } } } }