From b7ff9283400f314089724891421aa88c0a35fd6f Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 18 Jun 2026 13:46:28 +0200 Subject: [PATCH] Add Mixture-of-Agents (MoA) mode on top of Fusion Extend the panel engine with a layer loop: Fusion is one proposer round, MoA runs N rounds where each round after the first sees the previous round's responses via and refines its answer. Only the final round is persisted; intermediate rounds are live-only. Rounds are clamped to 1-5. - SessionRecord + migration: moa_layers column (default 2) - RunPanel: layer loop with per-member cloned message injection - CloneForMember / BuildPreviousLayerBlock helpers for member isolation - SSE: panel_layer_start / panel_layer_done events - Frontend: layer_index composite keying, round dividers, MoA option - Docs: ARCHITECTURE + ARCHITECTURE_QUICK updated Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 4 +- ARCHITECTURE_QUICK.md | 16 ++- Endpoints/ChatEndpoint.cs | 6 +- Endpoints/SessionEndpoint.cs | 3 +- Models/ChatContext.cs | 1 + Models/SessionRecord.cs | 3 +- Services/AnthropicService.Panel.cs | 171 +++++++++++++++++++++------ Services/ChatService.cs | 1 + Services/DatabaseService.Sessions.cs | 8 +- Services/DatabaseService.cs | 1 + wwwroot/css/chat.css | 28 +++++ wwwroot/index.html | 8 +- wwwroot/js/chat.js | 10 +- wwwroot/js/pages/session-page.js | 5 + wwwroot/js/stream-ui.js | 39 ++++-- 15 files changed, 239 insertions(+), 65 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 75be230..ee9c577 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -348,7 +348,7 @@ TelegramBot (1) ─── (*) Session (via telegram_bot_id) | `users` | Benutzer (username, password_hash, display_name, is_admin, persona, settings, default_path) | | `tokens` | Auth-Tokens (token → user_id) | | `agents` | AI-Agenten (model – z.B. `claude-sonnet-4-6` oder `nvidia:deepseek-ai/deepseek-r1`, custom_commands, auto_commit, use_soul/identity/user_info/memory/auto_context, use_github_search, use_serper_search, use_web_search, web_search_version, allow_user_choice, task_instruction, avatar_scale/offset, avatar_small, is_public) | -| `sessions` | Chat-Sessions (description, image, image_small, workspace, context, custom_commands, telegram_bot_id, telegram_chat_id, avatar_scale/offset, preparer_agent_id, review_agent_id, max_review_iterations, finalizer_agent_id, multi_model_mode, panel_agent_ids) | +| `sessions` | Chat-Sessions (description, image, image_small, workspace, context, custom_commands, telegram_bot_id, telegram_chat_id, avatar_scale/offset, preparer_agent_id, review_agent_id, max_review_iterations, finalizer_agent_id, multi_model_mode, panel_agent_ids, moa_layers) | | `chains` | Request-Chains (model, tokens, iterations, timing, tool_choice, tool_names, stream_error_info, plan_mode, archive_id) | | `messages` | Einzelne Nachrichten (text, tool_use, tool_result, error, web_search_result, auto_context), Tag für Sub-Agent-Markierung oder Import-Herkunft (`openclaw-import`), SortKey für korrekte Reihenfolge, thought_signature (Gemini) | | `attachments` | Datei-Anhänge (base64, image/document) | @@ -1191,7 +1191,7 @@ Admins können für jeden User einen **Autologin-Link** generieren – z.B. für Das Multi-Agent-System ermöglicht bis zu **vier Agents** pro Session: einen **Worker**, einen optionalen **Vorbereiter (Preparer)**, einen optionalen **Reviewer** und einen optionalen **Finalizer**. -> **Multi-Modell-Panel (Fusion):** Orthogonal dazu kann eine Session zusätzlich ein **Panel** aus mehreren Agenten haben (`multi_model_mode="fusion"`, `panel_agent_ids`=CSV). Bei jeder Nachricht antworten alle Panel-Agenten **parallel** (read-only) auf die Anfrage; ihre Antworten werden dem Worker als ``-Block vorgelegt, der **eine** Gesamtantwort synthetisiert und normal streamt (OpenRouter-Fusion-/MoA-Muster). Backend: `RunPanel()` in `AnthropicService.Panel.cs` (im ChatEndpoint-try vor dem Worker-Dispatch), Member-Provider-Weiche (Anthropic read-only via `PlanMode`, CLI via `--permission-mode plan`, OpenAI tool-los via `CompleteText`), Proposals als `[Panel: Name]`-Messages mit `Tag="panel"`. SSE: `panel_start`/`panel_member_start`/`panel_member_done`/`panel_done`. Frontend: `.panel-bubble` pro Member. Mehrschichtiges MoA ist als Aufbau geplant. Siehe ARCHITECTURE_QUICK.md. +> **Multi-Modell-Panel (Fusion / MoA):** Orthogonal dazu kann eine Session zusätzlich ein **Panel** aus mehreren Agenten haben (`multi_model_mode="fusion"`/`"moa"`, `panel_agent_ids`=CSV, `moa_layers`=Proposer-Runden bei MoA). Bei jeder Nachricht antworten alle Panel-Agenten **parallel** (read-only) und **streamen live** (CLI: `TextDelta`/`ToolStart`/`ToolResult`; Anthropic-API: pro Tool-Runde; OpenAI: one-shot); ihre Antworten werden dem Worker als ``-Block vorgelegt, der **eine** Gesamtantwort synthetisiert und normal streamt (OpenRouter-Fusion-/MoA-Muster). **MoA** = mehrere Proposer-Runden: ab Runde 2 sieht jeder Member die Vorrunde (``) und verfeinert; `RunPanel(layers)` ist EINE Engine (Fusion = `layers==1`), nur die finale Runde wird persistiert. Backend: `RunPanel()` in `AnthropicService.Panel.cs` (im ChatEndpoint-try vor dem Worker-Dispatch), Member-Provider-Weiche (Anthropic read-only via `PlanMode`, CLI via `--permission-mode plan`, OpenAI tool-los via `CompleteText`), Proposals als `[Panel: Name]`-Messages mit `Tag="panel"`, Write-Lock in `SseHelper` serialisiert parallele SSE-Flushes. SSE: `panel_start`/`panel_layer_start`/`panel_member_start`/`panel_member_text`/`panel_member_tool*`/`panel_member_done`/`panel_layer_done`/`panel_done`. Frontend: `.panel-bubble` pro Member (gekeyt `layer_index`), `.panel-layer-divider` bei MoA. Siehe ARCHITECTURE_QUICK.md. ### Architektur diff --git a/ARCHITECTURE_QUICK.md b/ARCHITECTURE_QUICK.md index 413777e..7372489 100644 --- a/ARCHITECTURE_QUICK.md +++ b/ARCHITECTURE_QUICK.md @@ -88,7 +88,7 @@ User 1──* TelegramBot 1──* Session (via telegram_bot_id) | `users` | Login, DisplayName, IsAdmin, Persona, Settings (JSON), DefaultPath | | `tokens` | Auth-Tokens → User | | `agents` | AI-Agenten (Model – z.B. `claude-sonnet-4-6` oder `nvidia:model-id`, CustomCommands, AutoCommit, UseSoul/Identity/UserInfo/Memory/AutoContext, UseGitHubSearch, UseSerperSearch, WebSearch, AllowUserChoice, TaskInstruction, Effort, AvatarScale/Offset, AvatarSmall, IsPublic) | -| `sessions` | Chat-Sessions (Description, Image, ImageSmall, Workspace, Context, CustomCommands, TelegramBotId, TelegramChatId, AvatarScale/Offset, PreparerAgentId, ReviewAgentId, MaxReviewIterations, FinalizerAgentId, MultiModelMode, PanelAgentIds) | +| `sessions` | Chat-Sessions (Description, Image, ImageSmall, Workspace, Context, CustomCommands, TelegramBotId, TelegramChatId, AvatarScale/Offset, PreparerAgentId, ReviewAgentId, MaxReviewIterations, FinalizerAgentId, MultiModelMode, PanelAgentIds, MoaLayers) | | `chains` | Ein Request-Response-Zyklus (Tokens, Timing, Iterations, PlanMode, ToolChoice, ToolNames, StreamErrorInfo, ArchiveId) | | `messages` | Einzelne Nachrichten (text / tool_use / tool_result / error / web_search_result / auto_context), Tag für Sub-Agent-Markierung oder Import-Herkunft (`openclaw-import`), thought_signature (Gemini) | | `attachments` | Base64-Anhänge (Bilder, Dokumente) | @@ -347,17 +347,21 @@ Eine Session kann bis zu **vier Agents** haben: Worker (Pflicht), Preparer (opti - History: `renderMarkdown` parst `[Preparer: AgentName]`, `[Vorbereitet von AgentName]`, `[Review: AgentName]`, `[Finalizer: AgentName]`, filtert `[APPROVED]`, `[FORWARD]`, `[QUESTION]` - Bubble-Klassen: `.preparer-bubble` (Info-Farbe), `.review-bubble` (sekundäre Farbe), `.finalizer-bubble` (grüne Farbe) -## Multi-Modell-Panel (Fusion) +## Multi-Modell-Panel (Fusion / MoA) -Zusätzlich zum Worker kann eine Session ein **Panel** aus mehreren Agenten haben (`multi_model_mode = "fusion"`, `panel_agent_ids` = CSV der Agent-IDs). Bei jeder Nachricht antworten alle Panel-Agenten **parallel** auf die Anfrage; ihre Antworten werden dem **Worker** als ``-Block in die letzte User-Message gemergt, der daraus **eine** Gesamtantwort synthetisiert und normal streamt (Fusion = OpenRouter-Fusion-/Mixture-of-Agents-Muster). Mehrschichtiges MoA ist als Aufbau geplant. +Zusätzlich zum Worker kann eine Session ein **Panel** aus mehreren Agenten haben (`multi_model_mode = "fusion"`/`"moa"`, `panel_agent_ids` = CSV der Agent-IDs, `moa_layers` = Proposer-Runden bei MoA, Default 2). Bei jeder Nachricht antworten alle Panel-Agenten **parallel** auf die Anfrage; ihre Antworten werden dem **Worker** als ``-Block in die letzte User-Message gemergt, der daraus **eine** Gesamtantwort synthetisiert und normal streamt (OpenRouter-Fusion-/Mixture-of-Agents-Muster). + +**Fusion vs. MoA:** Fusion = 1 Proposer-Runde → Synthese. **MoA** = `moa_layers` Runden (1–5, Backstop): ab Runde 2 sieht jeder Member die Antworten der Vorrunde (``, isoliert pro Member via `CloneForMember` gemergt) und verfeinert. `RunPanel(layers)` ist EINE Engine (Fusion = `layers==1`). Nur die **finale** Runde wird persistiert; Zwischenrunden sind **live-only** (überleben kein F5). **Member laufen read-only** (Anthropic: `FilterReadOnlyTools` via `PlanMode`, CLI: `--permission-mode plan`, OpenAI: tool-los via `OpenAiService.CompleteText`) → keine parallelen Schreibkonflikte. Concurrency-Cap (3) begrenzt parallele CLI-Children. -**Backend:** `ChatService.PrepareContext` baut `ChatContext.PanelAgents` (`List`) aus `panel_agent_ids`. `RunPanel()` (AnthropicService.Panel.cs) läuft im ChatEndpoint-try VOR dem Worker-Dispatch: Member parallel (`Task.WhenAll` + `SemaphoreSlim`), Provider-Weiche pro Member, jede Proposal als `[Panel: Name]`-Message mit `Tag="panel"` persistiert (aus Worker-History gefiltert, überlebt F5). `NonStreamingLoopCallbacks.Persist=false` verhindert Doppel-Persistenz der Member-Arbeit. Der Synthese-Block wird in den **bestehenden** Text-Block der letzten User-Message gemergt (damit auch der CLI-Worker via `ExtractLastUserText` ihn sieht). Panel-Fehler sind graceful (Worker läuft ohne Synthese weiter). +**Member streamen LIVE** (wie der Session-Agent): CLI forwardet `TextDelta`/`ToolStart`/`ToolResult`, Anthropic-API streamt pro Tool-Runde über `NonStreamingLoopCallbacks`, OpenAI = one-shot (Text am Ende). Ein `SemaphoreSlim`-Write-Lock in `SseHelper.TryWriteSse` serialisiert die SSE-Byte-Flushes paralleler Member (sonst vermischen sich die Frames). -**SSE-Events:** `panel_start`, `panel_member_start`, `panel_member_done` (voller Text), `panel_done`. +**Backend:** `ChatService.PrepareContext` baut `ChatContext.PanelAgents` (`List`) + `MoaLayers`. `RunPanel(layers)` (AnthropicService.Panel.cs) läuft im ChatEndpoint-try VOR dem Worker-Dispatch: pro Runde Member parallel (`Task.WhenAll` + `SemaphoreSlim`), Provider-Weiche pro Member, finale Proposals als `[Panel: Name]`-Message mit `Tag="panel"` persistiert (aus Worker-History gefiltert). `NonStreamingLoopCallbacks.Persist=false` verhindert Doppel-Persistenz. Der Synthese-Block wird in den **bestehenden** Text-Block der letzten User-Message gemergt (damit auch der CLI-Worker via `ExtractLastUserText` ihn sieht). Panel-Fehler sind graceful. -**Frontend:** `stream-ui.js` rendert pro Member eine `.panel-bubble` (keyed by index; non-streaming → Bubble wird bei `_done` gefüllt). History: `history.js _renderPanelMemberBubble` rendert `Tag="panel"`-Messages als Panel-Bubbles. Picker: `#multiModelMode`-Dropdown + Mehrfach-Agenten-Picker (`_openMultiPickerModal`) in der Session-Bearbeitung. +**SSE-Events:** `panel_start` (mode/layers), `panel_layer_start`/`panel_layer_done` (MoA-Runden), `panel_member_start` (mit `layer`), `panel_member_text`/`panel_member_tool_use`/`panel_member_tool_end`/`panel_member_tool` (live), `panel_member_done`, `panel_done`. + +**Frontend:** `stream-ui.js` rendert pro Member eine `.panel-bubble`, gekeyt per `layer_index` (MoA: gleiche Member über mehrere Runden; aktive Runde pro Index wird bei `panel_member_start` gemerkt, da Runden sequenziell laufen). `panel_layer_start` zeigt bei >1 Runde einen `.panel-layer-divider`. History: `history.js _renderPanelMemberBubble` rendert `Tag="panel"`-Messages. Picker: `#multiModelMode`-Dropdown (Aus/Fusion/MoA) + `#moaLayers` + Mehrfach-Agenten-Picker (`_openMultiPickerModal`). **Web-Search Sources:** - `web_search_result`-Messages in DB gespeichert (Sources als JSON-Array, leere werden übersprungen) diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 1b0fe11..ad1a106 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -236,11 +236,13 @@ public static class ChatEndpoint // danach als Synthesizer über den unveränderten Dispatch unten. Heartbeat läuft bereits, // Cancellation (workCt/sseCt) ist verfügbar. Nur bei aktivem Fusion-Modus + nicht-leerem // Panel + keiner Choice-Antwort. - if (!isChoiceResponse && ctx.MultiModelMode == "fusion" && ctx.PanelAgents != null && ctx.PanelAgents.Count > 0) + if (!isChoiceResponse && (ctx.MultiModelMode == "fusion" || ctx.MultiModelMode == "moa") && ctx.PanelAgents != null && ctx.PanelAgents.Count > 0) { try { - await anthropic.RunPanel(res, ctx.PanelAgents, messages, ctx.ChainId, sessionId, userApiKey?.ApiKey, workCt, sseCt); + // Fusion = 1 Runde, MoA = MoaLayers Proposer-Runden vor der Synthese. + var panelLayers = ctx.MultiModelMode == "moa" ? System.Math.Max(1, ctx.MoaLayers) : 1; + await anthropic.RunPanel(res, ctx.PanelAgents, messages, ctx.ChainId, sessionId, userApiKey?.ApiKey, panelLayers, workCt, sseCt); } catch (OperationCanceledException) { throw; } catch (Exception ex) diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index 4bb6fea..ac658da 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -148,8 +148,9 @@ public static class SessionEndpoint var preparerAgentId = body.TryGetProperty("preparerAgentId", out var pra) ? pra.GetString() : null; var multiModelMode = body.TryGetProperty("multiModelMode", out var mmm) ? mmm.GetString() : null; var panelAgentIds = body.TryGetProperty("panelAgentIds", out var pai) && pai.ValueKind != JsonValueKind.Null ? pai.GetString() : null; + var moaLayers = body.TryGetProperty("moaLayers", out var mol) && mol.ValueKind == JsonValueKind.Number ? mol.GetInt32() : 2; - var session = await db.CreateSession(userId, name, description, image, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId, multiModelMode, panelAgentIds); + var session = await db.CreateSession(userId, name, description, image, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId, multiModelMode, panelAgentIds, moaLayers); await ctx.Response.WriteAsJsonAsync(session); } diff --git a/Models/ChatContext.cs b/Models/ChatContext.cs index cb57be3..aefb8e1 100644 --- a/Models/ChatContext.cs +++ b/Models/ChatContext.cs @@ -37,6 +37,7 @@ public class ChatContext // der Worker synthetisiert. "off" / "fusion" (später "moa"). public string MultiModelMode { get; set; } public List PanelAgents { get; set; } + public int MoaLayers { get; set; } = 2; // MoA: Anzahl Proposer-Runden (nur bei mode="moa") } public class SubAgentContext diff --git a/Models/SessionRecord.cs b/Models/SessionRecord.cs index f78ce63..ca48da8 100644 --- a/Models/SessionRecord.cs +++ b/Models/SessionRecord.cs @@ -23,8 +23,9 @@ public class SessionRecord public int MaxReviewIterations { get; set; } = 3; public string FinalizerAgentId { get; set; } public string PreparerAgentId { get; set; } - public string MultiModelMode { get; set; } // "off" / "fusion" (später "moa") + public string MultiModelMode { get; set; } // "off" / "fusion" / "moa" public string PanelAgentIds { get; set; } // CSV der Panel-Agenten (Proposer) + public int MoaLayers { get; set; } = 2; // MoA: Anzahl Proposer-Runden vor der Synthese (Fusion ignoriert) public string UnityMcpUrl { get; set; } public string UnityMcpToken { get; set; } public string CreatedAt { get; set; } diff --git a/Services/AnthropicService.Panel.cs b/Services/AnthropicService.Panel.cs index 500661b..68b529b 100644 --- a/Services/AnthropicService.Panel.cs +++ b/Services/AnthropicService.Panel.cs @@ -12,13 +12,17 @@ namespace Gott.Services; public partial class AnthropicService { - // ── Multi-Modell-Panel (Fusion) ── + // ── Multi-Modell-Panel (Fusion / MoA) ── // // Mehrere Panel-Agenten ("Proposer") beantworten dieselbe Anfrage PARALLEL und - // non-streaming. Anschließend werden ihre Antworten dem Worker-Agent als + // streamen live. Anschließend werden ihre Antworten dem Worker-Agent als // -Block vorgelegt, der daraus EINE Gesamtantwort synthetisiert // und über die unveränderte Streaming-Pipeline ausgibt. // + // MoA (Mixture-of-Agents): layers>1 → mehrere Proposer-Runden; jede Runde k>1 sieht + // die Antworten der Vorrunde () und verfeinert. Fusion = layers==1. + // Nur die FINALE Runde wird persistiert (Zwischen-Runden sind live-only). + // // 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 @@ -34,10 +38,11 @@ public partial class AnthropicService } /// - /// 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 + /// Führt das Panel aus (Fusion = 1 Runde, MoA = Runden): pro Runde + /// alle Member parallel; ab Runde 2 sieht jeder Member die Antworten der Vorrunde. Die FINALE + /// Runde wird persistiert (Tag="panel"); ihr Synthese-Block wird in die letzte User-Message von + /// gemergt. Danach streamt der Worker als Synthesizer ganz + /// normal weiter. Gibt die Anzahl beigetragener Member der finalen Runde zurück /// (0 = nichts injiziert → Worker antwortet regulär). /// public async Task RunPanel( @@ -47,47 +52,65 @@ public partial class AnthropicService string chainId, string sessionId, string apiToken, + int layers, CancellationToken workCt, CancellationToken sseCt) { if (panel == null || panel.Count == 0) return 0; + layers = Math.Clamp(layers, 1, 5); // Kosten-Backstop: max. 5 Proposer-Runden // 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); + var mode = layers > 1 ? "moa" : "fusion"; + await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode, layers }, 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++) + // MoA: mehrere Proposer-Runden. Runde k>1 bekommt und verfeinert. + // Fusion = layers==1 (keine Vorrunde). prev füttert die nächste Runde bzw. die finale Synthese. + List prev = null; + for (int layer = 1; layer <= layers; layer++) { - var member = panel[i]; - var index = i; - tasks.Add(Task.Run(async () => + bool isFinalLayer = layer == layers; + await SseHelper.TryWriteSse(response, "panel_layer_start", new { layer, total = layers }, sseCt, sseGate); + + string prevBlock = prev == null ? null : BuildPreviousLayerBlock(prev, layer, layers); + + // 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++) { - await sem.WaitAsync(workCt); - try { return await RunPanelMember(response, member, workerMessages, index, chainId, sessionId, apiToken, sseGate, workCt, sseCt); } - finally { sem.Release(); } - }, workCt)); + var member = panel[i]; + var index = i; + var lyr = layer; + tasks.Add(Task.Run(async () => + { + await sem.WaitAsync(workCt); + try { return await RunPanelMember(response, member, workerMessages, index, lyr, layers, prevBlock, isFinalLayer, 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(); + + await SseHelper.TryWriteSse(response, "panel_layer_done", new { layer, total = layers, members = ordered.Count }, sseCt, sseGate); + + if (ordered.Count == 0) + { + // Keine verwertbaren Antworten in dieser Runde → ohne Synthese weiter. + await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt, sseGate); + return 0; + } + prev = ordered; } - 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. + // Synthese-Block aus der LETZTEN Runde bauen und in die letzte User-Message einmergen. var sb = new StringBuilder(); sb.AppendLine(); sb.AppendLine("---"); @@ -98,7 +121,7 @@ public partial class AnthropicService + "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) + foreach (var r in prev) { sb.AppendLine(); sb.AppendLine($"### Antwort von {r.Name} ({r.Model}):"); @@ -108,8 +131,8 @@ public partial class AnthropicService MergePanelIntoLastUserText(workerMessages, sb.ToString()); - await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = ordered.Count }, sseCt, sseGate); - return ordered.Count; + await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = prev.Count }, sseCt, sseGate); + return prev.Count; } /// @@ -122,6 +145,10 @@ public partial class AnthropicService SubAgentContext member, List workerMessages, int index, + int layer, + int totalLayers, + string previousLayerBlock, + bool persist, string chainId, string sessionId, string apiToken, @@ -130,9 +157,12 @@ public partial class AnthropicService 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); + await SseHelper.TryWriteSse(response, "panel_member_start", new { index, name, model = member.ModelId, layer, totalLayers }, sseCt, sseGate); - var memberMessages = new List(workerMessages); // eigene Kopie pro Member + // Eigene, ISOLIERTE Kopie pro Member. Bei MoA (Runde > 1) wird der Vorrunden-Block in den + // bestehenden Text-Block der letzten User-Message gemergt – isoliert, damit er nicht in + // workerMessages oder parallele Member leckt. + var memberMessages = CloneForMember(workerMessages, previousLayerBlock); string text; try { @@ -180,7 +210,8 @@ public partial class AnthropicService // 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)) + // Nur die FINALE Runde persistieren (MoA-Zwischenrunden sind live-only, überleben kein F5). + if (persist && !string.IsNullOrEmpty(text)) { await _db.SaveMessage(new MessageRecord { @@ -302,4 +333,68 @@ public partial class AnthropicService } } } + + /// + /// Liefert eine pro-Member isolierte Kopie der Worker-Messages. Ist + /// gesetzt (MoA, Runde > 1), wird er in den bestehenden Text-Block der letzten User-Message + /// gemergt – auf einer GEKLONTEN Message + Content-Liste, damit die Mutation NICHT in + /// workerMessages oder parallele Member leckt. block==null → einfache Shallow-Kopie (wie Fusion). + /// + private static List CloneForMember(List messages, string block) + { + var copy = new List(messages); + if (string.IsNullOrEmpty(block)) return copy; + for (int i = copy.Count - 1; i >= 0; i--) + { + if (copy[i] is Dictionary d && + d.TryGetValue("role", out var r) && (r as string) == "user" && + d.TryGetValue("content", out var c) && c is List contentList) + { + var newContent = new List(contentList); + var newD = new Dictionary(d) { ["content"] = newContent }; + bool merged = false; + for (int j = 0; j < newContent.Count; j++) + { + var json = JsonSerializer.Serialize(newContent[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() ?? "" : ""; + newContent[j] = new { type = "text", text = orig + "\n\n" + block }; + merged = true; + break; + } + } + if (!merged) newContent.Add(new { type = "text", text = block }); + copy[i] = newD; + return copy; + } + } + return copy; + } + + /// + /// Baut den <previous_layer_responses>-Block für MoA: die Antworten der Vorrunde als + /// Referenz, mit der Anweisung, die eigene Antwort zu VERBESSERN (nicht blind zu kopieren). + /// + private static string BuildPreviousLayerBlock(List prev, int layer, int total) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine($"[Multi-Modell-Panel · Runde {layer} von {total}] Andere KI-Agenten haben in der vorherigen Runde " + + "auf dieselbe Anfrage geantwortet. Nutze ihre Antworten als zusätzliche Referenz: übernimm korrekte Fakten " + + "und gute Ideen, korrigiere erkennbare Fehler und VERBESSERE deine eigene Antwort. Kopiere nicht blind – " + + "denke kritisch und liefere die bestmögliche eigenständige Antwort auf die ursprüngliche Anfrage."); + sb.AppendLine(); + sb.AppendLine(""); + foreach (var r in prev) + { + sb.AppendLine(); + sb.AppendLine($"### Antwort von {r.Name} ({r.Model}):"); + sb.AppendLine(r.Text.Trim()); + } + sb.AppendLine(""); + return sb.ToString(); + } } diff --git a/Services/ChatService.cs b/Services/ChatService.cs index f9cb7e2..931a7a1 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -222,6 +222,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? Finalizer = finalizerContext, MultiModelMode = session.MultiModelMode, PanelAgents = panelContexts, + MoaLayers = session.MoaLayers, PlanMode = planMode }; } diff --git a/Services/DatabaseService.Sessions.cs b/Services/DatabaseService.Sessions.cs index a3ae3eb..5cfb62c 100644 --- a/Services/DatabaseService.Sessions.cs +++ b/Services/DatabaseService.Sessions.cs @@ -12,7 +12,7 @@ public partial class DatabaseService { // ── Sessions ── - public async Task CreateSession(string userId, string name = null, string description = null, string image = null, string imageSmall = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3, string finalizerAgentId = null, string preparerAgentId = null, string multiModelMode = null, string panelAgentIds = null) + public async Task CreateSession(string userId, string name = null, string description = null, string image = null, string imageSmall = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3, string finalizerAgentId = null, string preparerAgentId = null, string multiModelMode = null, string panelAgentIds = null, int moaLayers = 2) { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); @@ -37,12 +37,13 @@ public partial class DatabaseService PreparerAgentId = preparerAgentId, MultiModelMode = multiModelMode, PanelAgentIds = panelAgentIds, + MoaLayers = moaLayers, CreatedAt = now, UpdatedAt = now }; await conn.ExecuteAsync(@" - INSERT INTO sessions (session_id, user_id, name, description, image, image_small, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, preparer_agent_id, multi_model_mode, panel_agent_ids, created_at, updated_at) - VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @MultiModelMode, @PanelAgentIds, @CreatedAt, @UpdatedAt) + INSERT INTO sessions (session_id, user_id, name, description, image, image_small, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, preparer_agent_id, multi_model_mode, panel_agent_ids, moa_layers, created_at, updated_at) + VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @MultiModelMode, @PanelAgentIds, @MoaLayers, @CreatedAt, @UpdatedAt) ", session); return session; } @@ -159,6 +160,7 @@ public partial class DatabaseService if (body.TryGetProperty("preparerAgentId", out var pa)) { sets.Add("preparer_agent_id = @preparerAgentId"); p.Add("preparerAgentId", pa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : pa.GetString()); } if (body.TryGetProperty("multiModelMode", out var mmm)) { sets.Add("multi_model_mode = @multiModelMode"); p.Add("multiModelMode", mmm.ValueKind == System.Text.Json.JsonValueKind.Null ? null : mmm.GetString()); } if (body.TryGetProperty("panelAgentIds", out var pai)) { sets.Add("panel_agent_ids = @panelAgentIds"); p.Add("panelAgentIds", pai.ValueKind == System.Text.Json.JsonValueKind.Null ? null : pai.GetString()); } + if (body.TryGetProperty("moaLayers", out var mol) && mol.ValueKind == System.Text.Json.JsonValueKind.Number) { sets.Add("moa_layers = @moaLayers"); p.Add("moaLayers", mol.GetInt32()); } if (body.TryGetProperty("unityMcpUrl", out var umu)) { sets.Add("unity_mcp_url = @unityMcpUrl"); p.Add("unityMcpUrl", umu.ValueKind == System.Text.Json.JsonValueKind.Null ? null : umu.GetString()); } if (body.TryGetProperty("unityMcpToken", out var umt)) { sets.Add("unity_mcp_token = @unityMcpToken"); p.Add("unityMcpToken", umt.ValueKind == System.Text.Json.JsonValueKind.Null ? null : umt.GetString()); } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index f664654..e51c897 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -200,6 +200,7 @@ public partial class DatabaseService TryAddColumn(conn, "chains", "effort", "TEXT NOT NULL DEFAULT ''"); TryAddColumn(conn, "sessions", "multi_model_mode", "TEXT"); TryAddColumn(conn, "sessions", "panel_agent_ids", "TEXT"); + TryAddColumn(conn, "sessions", "moa_layers", "INTEGER NOT NULL DEFAULT 2"); // Migration: 1M-Suffix entfernen (Mai 2026). Seit März 2026 ist 1M-Context Default // bei Opus 4.6/4.7/4.8 + Sonnet 4.6 – kein "[1m]"-Suffix mehr nötig. diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index e984853..de94abb 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -1227,6 +1227,34 @@ border-color: var(--accent, #9b6dff); } +/* MoA: Trenner zwischen Proposer-Runden */ +.panel-layer-divider { + display: flex; + align-items: center; + gap: .6em; + margin: 1.1em 0 .5em; + color: var(--accent, #9b6dff); + font-size: .82em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + opacity: .85; +} +.panel-layer-divider::before, +.panel-layer-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--accent, #9b6dff); + opacity: .35; +} +.panel-layer-divider span { + display: inline-flex; + align-items: center; + gap: .4em; + white-space: nowrap; +} + .review-inline-feedback { color: var(--warning); margin-bottom: .4em; diff --git a/wwwroot/index.html b/wwwroot/index.html index da194fe..0ebc534 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -156,10 +156,11 @@
Kein Finalizer-Agent
- + +
diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 9d079e5..5fdbcef 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -128,11 +128,19 @@ function handleStreamEvent(eventType, data) { finalizeReviewBubble('done', data, 'finalizer'); break; - // Multi-Modell-Panel (Fusion): mehrere Member streamen parallel LIVE + // 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; diff --git a/wwwroot/js/pages/session-page.js b/wwwroot/js/pages/session-page.js index d7e835f..e0eb3ce 100644 --- a/wwwroot/js/pages/session-page.js +++ b/wwwroot/js/pages/session-page.js @@ -33,6 +33,8 @@ registerPage('session', { const panelAgentsRow = document.getElementById('panelAgentsRow'); const panelAgentsCard = document.getElementById('panelAgentsCard'); const panelAgentIdsEl = document.getElementById('panelAgentIds'); + const moaLayersRow = document.getElementById('moaLayersRow'); + const moaLayersEl = document.getElementById('moaLayers'); const imagePreview = document.getElementById('sessionImagePreview'); const saveBtn = document.getElementById('sessionSaveBtn'); const cancelBtn = document.getElementById('sessionCancelBtn'); @@ -148,9 +150,11 @@ registerPage('session', { } function updatePanelVisibility() { panelAgentsRow.hidden = (panelModeEl.value === 'off'); + if (moaLayersRow) moaLayersRow.hidden = (panelModeEl.value !== 'moa'); } panelModeEl.value = (session && session.multiModelMode) || 'off'; panelAgentIdsEl.value = (session && session.panelAgentIds) || ''; + if (moaLayersEl) moaLayersEl.value = (session && session.moaLayers) || 2; renderPanelInCard(); updatePanelVisibility(); panelModeEl.addEventListener('change', updatePanelVisibility); @@ -215,6 +219,7 @@ registerPage('session', { preparerAgentId: preparerAgentSelect.value || null, multiModelMode: panelModeEl.value || 'off', panelAgentIds: panelAgentIdsEl.value || null, + moaLayers: moaLayersEl ? (parseInt(moaLayersEl.value, 10) || 2) : 2, context: _sessionContextMde.value().trim() || null, workspace: workspaceEl.value.trim() || null, customCommands: commands, diff --git a/wwwroot/js/stream-ui.js b/wwwroot/js/stream-ui.js index 8d623f5..0f94b37 100644 --- a/wwwroot/js/stream-ui.js +++ b/wwwroot/js/stream-ui.js @@ -206,19 +206,36 @@ function finalizeReviewBubble(type, data, agentType) { scrollDown(); } -// ── 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. +// ── Panel Bubbles (Fusion/MoA: mehrere Member streamen parallel LIVE) ── +// Pro Member eine eigene Bubble. Bei MoA wiederholen sich die Member über mehrere Runden +// (gleicher index, andere layer) → Bubbles werden per "layer_index" gekeyt. Da die Runden +// SEQUENZIELL laufen, genügt es, die aktive Runde pro index zu merken (gesetzt bei +// panel_member_start); die Live-Text/Tool-Events tragen nur den index und finden so ihre Bubble. let _panelBubbles = {}; +let _panelActiveLayer = {}; // index -> aktuell laufende Runde (MoA) + +function _panelKey(index, layer) { return (layer || 1) + '_' + index; } +function _panelGetBubble(index) { return _panelBubbles[_panelKey(index, _panelActiveLayer[index])]; } function startPanel(data) { endStreamBubble(); _panelBubbles = {}; + _panelActiveLayer = {}; +} + +// MoA: Runden-Trenner (nur bei mehr als einer Runde sichtbar). +function startPanelLayer(data) { + if (!data || !data.total || data.total <= 1) return; + var div = document.createElement('div'); + div.className = 'panel-layer-divider'; + div.innerHTML = ' Runde ' + data.layer + ' / ' + data.total + ''; + chatEl().appendChild(div); + scrollDown(); } function addPanelMemberBubble(data) { + var layer = data.layer || 1; var bubble = document.createElement('div'); bubble.className = 'msg review-bubble panel-bubble streamed'; var model = (typeof prettyModelName === 'function') ? prettyModelName(data.model) : data.model; @@ -232,7 +249,8 @@ function addPanelMemberBubble(data) { 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; + _panelActiveLayer[data.index] = layer; // ab jetzt zeigen Events für diesen index auf diese Runde + _panelBubbles[_panelKey(data.index, layer)] = bubble; chatEl().appendChild(bubble); scrollDown(); } @@ -242,7 +260,7 @@ function _panelClearSpinner(bubble) { } function appendPanelMemberText(data) { - var bubble = _panelBubbles[data.index]; + var bubble = _panelGetBubble(data.index); if (!bubble || !data.text) return; _panelClearSpinner(bubble); // Neues Text-Segment beginnen, wenn keins offen ist (z.B. direkt nach einer Tool-Box) @@ -259,7 +277,7 @@ function appendPanelMemberText(data) { } function addPanelMemberToolUse(data) { - var bubble = _panelBubbles[data.index]; + var bubble = _panelGetBubble(data.index); if (!bubble) return; _panelClearSpinner(bubble); bubble._segText = null; // Text-Segment schließen → Folge-Text kommt UNTER die Tool-Box @@ -275,7 +293,7 @@ function addPanelMemberToolUse(data) { } function finishPanelMemberTool(data) { - var bubble = _panelBubbles[data.index]; + var bubble = _panelGetBubble(data.index); if (!bubble) return; _panelClearSpinner(bubble); bubble._segText = null; // nach Tool-Result neuen Text-Block beginnen @@ -295,7 +313,7 @@ function finishPanelMemberTool(data) { } function finalizePanelMember(data) { - var bubble = _panelBubbles[data.index]; + var bubble = _panelGetBubble(data.index); if (!bubble) return; _panelClearSpinner(bubble); // Wenn LIVE nichts gestreamt wurde (z.B. OpenAI-Member = one-shot): finalen Text rendern. @@ -308,10 +326,11 @@ function finalizePanelMember(data) { } bubble._segText.innerHTML = renderMarkdown(text); } - delete _panelBubbles[data.index]; + delete _panelBubbles[_panelKey(data.index, _panelActiveLayer[data.index])]; scrollDown(); } function endPanel(data) { _panelBubbles = {}; + _panelActiveLayer = {}; }