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 <previous_layer_responses> 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 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-18 13:46:28 +02:00
1 parent f21ed9ff9f
commit b7ff928340
15 files changed
+239 -65

No files matched your search

+2 -2
View File
@@ -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 `<panel_responses>`-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 `<panel_responses>`-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 (`<previous_layer_responses>`) 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
+10 -6
View File
@@ -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 `<panel_responses>`-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 `<panel_responses>`-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 (`<previous_layer_responses>`, 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<SubAgentContext>`) 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<SubAgentContext>`) + `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)
+4 -2
View File
@@ -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)
+2 -1
View File
@@ -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);
}
+1
View File
@@ -37,6 +37,7 @@ public class ChatContext
// der Worker synthetisiert. "off" / "fusion" (später "moa").
public string MultiModelMode { get; set; }
public List<SubAgentContext> PanelAgents { get; set; }
public int MoaLayers { get; set; } = 2; // MoA: Anzahl Proposer-Runden (nur bei mode="moa")
}
public class SubAgentContext
+2 -1
View File
@@ -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; }
+133 -38
View File
@@ -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
// <panel_responses>-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 (<previous_layer_responses>) 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
}
/// <summary>
/// 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 <paramref name="workerMessages"/> 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 = <paramref name="layers"/> 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
/// <paramref name="workerMessages"/> 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).
/// </summary>
public async Task<int> 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<Task<PanelMemberResult>>();
for (int i = 0; i < panel.Count; i++)
// MoA: mehrere Proposer-Runden. Runde k>1 bekommt <previous_layer_responses> und verfeinert.
// Fusion = layers==1 (keine Vorrunde). prev füttert die nächste Runde bzw. die finale Synthese.
List<PanelMemberResult> 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<Task<PanelMemberResult>>();
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("<panel_responses>");
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;
}
/// <summary>
@@ -122,6 +145,10 @@ public partial class AnthropicService
SubAgentContext member,
List<object> 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<object>(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
}
}
}
/// <summary>
/// Liefert eine pro-Member isolierte Kopie der Worker-Messages. Ist <paramref name="block"/>
/// gesetzt (MoA, Runde &gt; 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).
/// </summary>
private static List<object> CloneForMember(List<object> messages, string block)
{
var copy = new List<object>(messages);
if (string.IsNullOrEmpty(block)) return copy;
for (int i = copy.Count - 1; i >= 0; i--)
{
if (copy[i] is Dictionary<string, object> d &&
d.TryGetValue("role", out var r) && (r as string) == "user" &&
d.TryGetValue("content", out var c) && c is List<object> contentList)
{
var newContent = new List<object>(contentList);
var newD = new Dictionary<string, object>(d) { ["content"] = newContent };
bool merged = false;
for (int j = 0; j < newContent.Count; j++)
{
var json = JsonSerializer.Serialize(newContent[j]);
var el = JsonSerializer.Deserialize<JsonElement>(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;
}
/// <summary>
/// Baut den &lt;previous_layer_responses&gt;-Block für MoA: die Antworten der Vorrunde als
/// Referenz, mit der Anweisung, die eigene Antwort zu VERBESSERN (nicht blind zu kopieren).
/// </summary>
private static string BuildPreviousLayerBlock(List<PanelMemberResult> 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("<previous_layer_responses>");
foreach (var r in prev)
{
sb.AppendLine();
sb.AppendLine($"### Antwort von {r.Name} ({r.Model}):");
sb.AppendLine(r.Text.Trim());
}
sb.AppendLine("</previous_layer_responses>");
return sb.ToString();
}
}
+1
View File
@@ -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
};
}
+5 -3
View File
@@ -12,7 +12,7 @@ public partial class DatabaseService
{
// ── Sessions ──
public async Task<SessionRecord> 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<SessionRecord> 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()); }
+1
View File
@@ -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.
+28
View File
@@ -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;
+7 -1
View File
@@ -156,10 +156,11 @@
<div class="agent-select-empty"><i class="fa fa-flag-checkered"></i> Kein Finalizer-Agent</div>
</div>
<input type="hidden" id="finalizerAgentSelect" value="">
<label>Multi-Modell-Panel <small>(Fusion)</small></label>
<label>Multi-Modell-Panel <small>(Fusion / MoA)</small></label>
<select id="multiModelMode">
<option value="off">Aus</option>
<option value="fusion">Fusion – Panel antwortet parallel, Session-Agent synthetisiert</option>
<option value="moa">MoA – mehrere Proposer-Runden, dann Synthese</option>
</select>
<div id="panelAgentsRow" hidden>
<label>Panel-Agenten <small>(antworten parallel zur Anfrage)</small></label>
@@ -169,6 +170,11 @@
<input type="hidden" id="panelAgentIds" value="">
<small class="muted">Jeder Panel-Agent kostet zusätzlich Tokens/Zeit – alle laufen pro Nachricht. Member arbeiten read-only.</small>
</div>
<div id="moaLayersRow" hidden>
<label>MoA-Runden <small>(Proposer-Runden vor der Synthese)</small></label>
<input type="number" id="moaLayers" min="1" max="5" step="1" value="2">
<small class="muted">Mehr Runden = mehr Verfeinerung, aber Kosten/Zeit ≈ Runden × Agenten. Empfohlen: 2–3.</small>
</div>
<label>Benutzerdefinierte Befehle</label>
<div class="custom-commands" id="customCommandsList"></div>
<button type="button" id="addCommandBtn" class="add-cmd-btn">+ Befehl hinzufügen</button>
+9 -1
View File
@@ -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;
+5
View File
@@ -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,
+29 -10
View File
@@ -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 = '<span><i class="fa fa-clone"></i> Runde ' + data.layer + ' / ' + data.total + '</span>';
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 = {};
}