From 268f11fa6d4866e3141179f34e765fb50f9b3e35 Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 18 Jun 2026 12:55:06 +0200 Subject: [PATCH] Add multi-model panel with Fusion mode Panel agents answer the user prompt in parallel (read-only, non-streaming); their responses are merged into the worker's last user message as a block, and the session agent synthesizes one final streamed answer. The RunPanel engine is built layer-ready for future MoA. - New session fields: multi_model_mode (off/fusion), panel_agent_ids (CSV) with idempotent column migration - New partial Services/AnthropicService.Panel.cs holding the RunPanel engine - Provider-agnostic members: Anthropic RunNonStreamingToolLoop / OpenAI CompleteText / CLI plan-mode; all forced read-only, persisted with Tag=panel - SSE events: panel_start, panel_member_start, panel_member_done, panel_done - Frontend: multi-agent picker, live panel bubbles, history rendering - Graceful degradation: panel errors fall back to worker-only Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 6 +- ARCHITECTURE_QUICK.md | 14 +- Endpoints/ChatEndpoint.cs | 20 ++ Endpoints/SessionEndpoint.cs | 4 +- Models/ChatContext.cs | 5 + Models/SessionRecord.cs | 2 + Services/AnthropicService.Agents.cs | 65 ++++--- Services/AnthropicService.Panel.cs | 266 +++++++++++++++++++++++++++ Services/ChatService.cs | 18 ++ Services/DatabaseService.Sessions.cs | 10 +- Services/DatabaseService.cs | 2 + Services/OpenAiService.cs | 50 +++++ wwwroot/css/chat.css | 8 + wwwroot/index.html | 13 ++ wwwroot/js/chat.js | 17 ++ wwwroot/js/history.js | 34 ++++ wwwroot/js/modals.js | 55 ++++++ wwwroot/js/pages/session-page.js | 48 +++++ wwwroot/js/stream-ui.js | 39 ++++ wwwroot/js/ui-helpers.js | 6 +- 20 files changed, 647 insertions(+), 35 deletions(-) create mode 100644 Services/AnthropicService.Panel.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2377e34..75be230 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) | +| `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) | | `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) | @@ -392,7 +392,7 @@ TelegramBot (1) ─── (*) Session (via telegram_bot_id) - `Iteration` – Welche Tool-Loop-Iteration - `AttachmentCount` – Anzahl angehängter Dateien - `AttachmentNames` – JSON-Array mit Dateinamen -- `Tag` – Markierung (`null` = normal, `"preparer"/"review"/"finalizer"` = Sub-Agent-Message (wird aus API-History gefiltert), `"openclaw-import"` = importierte Nachricht (wird immer in History einbezogen)) +- `Tag` – Markierung (`null` = normal, `"preparer"/"review"/"finalizer"/"panel"` = Sub-Agent-Message (wird aus API-History gefiltert), `"openclaw-import"` = importierte Nachricht (wird immer in History einbezogen)) - `IsMarked` – Als wichtig markierte Nachricht (wird in System-Prompt aufgenommen) - `ThoughtSignature` – Gemini thought_signature (für Gemini-Tool-Calls, wird im nächsten Request zurückgesendet) - `DeletedOn` – Soft-Delete Zeitstempel @@ -1191,6 +1191,8 @@ 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. + ### Architektur ``` diff --git a/ARCHITECTURE_QUICK.md b/ARCHITECTURE_QUICK.md index 1ad0b82..413777e 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) | +| `sessions` | Chat-Sessions (Description, Image, ImageSmall, Workspace, Context, CustomCommands, TelegramBotId, TelegramChatId, AvatarScale/Offset, PreparerAgentId, ReviewAgentId, MaxReviewIterations, FinalizerAgentId, MultiModelMode, PanelAgentIds) | | `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,6 +347,18 @@ 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) + +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. + +**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). + +**SSE-Events:** `panel_start`, `panel_member_start`, `panel_member_done` (voller Text), `panel_done`. + +**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. + **Web-Search Sources:** - `web_search_result`-Messages in DB gespeichert (Sources als JSON-Array, leere werden übersprungen) - History + Shared-Page: Tool-Box mit klickbaren Source-Links (Domain + Title) diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index a08dde1..1b0fe11 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -230,6 +230,26 @@ public static class ChatEndpoint try { + // ── Multi-Modell-Panel (Fusion) ── + // Vor dem Worker: Panel-Agenten parallel antworten lassen und ihre Antworten dem + // Worker als Synthese-Block in die letzte User-Message einmergen. Der Worker streamt + // 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) + { + try + { + await anthropic.RunPanel(res, ctx.PanelAgents, messages, ctx.ChainId, sessionId, userApiKey?.ApiKey, workCt, sseCt); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + // Panel-Fehler darf den Worker nicht killen → ohne Synthese normal weiter. + Console.WriteLine($"[Panel] Fehler, fahre ohne Synthese fort: {ex.Message}"); + } + } + if (!string.IsNullOrEmpty(ctx.Provider)) { // OpenAI-kompatibler Provider – Key des Users für diesen Agent verwenden diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index a6a54ea..4bb6fea 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -146,8 +146,10 @@ public static class SessionEndpoint var maxReviewIterations = body.TryGetProperty("maxReviewIterations", out var mri) ? mri.GetInt32() : 3; var finalizerAgentId = body.TryGetProperty("finalizerAgentId", out var fa) ? fa.GetString() : null; 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 session = await db.CreateSession(userId, name, description, image, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId); + var session = await db.CreateSession(userId, name, description, image, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId, multiModelMode, panelAgentIds); await ctx.Response.WriteAsJsonAsync(session); } diff --git a/Models/ChatContext.cs b/Models/ChatContext.cs index dd268b1..cb57be3 100644 --- a/Models/ChatContext.cs +++ b/Models/ChatContext.cs @@ -32,6 +32,11 @@ public class ChatContext // Finalizer-Agent public SubAgentContext Finalizer { get; set; } + + // Multi-Modell-Panel (Fusion): mehrere Agenten antworten parallel, + // der Worker synthetisiert. "off" / "fusion" (später "moa"). + public string MultiModelMode { get; set; } + public List PanelAgents { get; set; } } public class SubAgentContext diff --git a/Models/SessionRecord.cs b/Models/SessionRecord.cs index c34ce99..f78ce63 100644 --- a/Models/SessionRecord.cs +++ b/Models/SessionRecord.cs @@ -23,6 +23,8 @@ 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 PanelAgentIds { get; set; } // CSV der Panel-Agenten (Proposer) public string UnityMcpUrl { get; set; } public string UnityMcpToken { get; set; } public string CreatedAt { get; set; } diff --git a/Services/AnthropicService.Agents.cs b/Services/AnthropicService.Agents.cs index 2bbdd19..6546556 100644 --- a/Services/AnthropicService.Agents.cs +++ b/Services/AnthropicService.Agents.cs @@ -422,6 +422,11 @@ public partial class AnthropicService /// Ob Text-Embeddings in die Queue geschrieben werden. public bool EnqueueEmbeddings { get; set; } + /// Ob Messages in die DB persistiert werden. false = Panel-Member: + /// die interne Arbeit (Text/Tool-Calls) wird nicht gespeichert, der Caller + /// persistiert nur die finale Proposal-Message selbst (mit [Panel: Name]-Prefix). + public bool Persist { get; set; } = true; + /// /// Optionaler Tool-Executor. Parameter: (toolName, input, ct) → result. /// Bei Fehler Exception werfen. Wenn null, wird Standard-Executor verwendet. @@ -509,12 +514,13 @@ public partial class AnthropicService { var errBody = await httpResp.Content.ReadAsStringAsync(ct); var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}"; - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = baseIteration + iterations, - Role = "system", Type = "error", - Content = errMsg, IsError = true, Tag = cb.Tag - }); + if (cb.Persist) + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = baseIteration + iterations, + Role = "system", Type = "error", + Content = errMsg, IsError = true, Tag = cb.Tag + }); if (cb.SseResponse != null) await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { error = errMsg }, cb.SseCt); break; @@ -542,14 +548,17 @@ public partial class AnthropicService finalText.Append(text); assistantContent.Add(new { type = "text", text }); - var msgId = await _db.SaveMessage(new MessageRecord + if (cb.Persist) { - ChainId = chainId, Iteration = baseIteration + iterations, - Role = "assistant", Type = "text", - Content = text, Tag = cb.Tag - }); - if (cb.EnqueueEmbeddings) - _embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text); + var msgId = await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = baseIteration + iterations, + Role = "assistant", Type = "text", + Content = text, Tag = cb.Tag + }); + if (cb.EnqueueEmbeddings) + _embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text); + } if (cb.SseResponse != null) await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { text, agentName = cb.AgentName }, cb.SseCt); } @@ -563,13 +572,14 @@ public partial class AnthropicService assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = inputEl }); - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = baseIteration + iterations, - Role = "assistant", Type = "tool_use", - ToolName = toolName, ToolCallId = toolId, - Content = inputEl.ToString(), Tag = cb.Tag - }); + if (cb.Persist) + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = baseIteration + iterations, + Role = "assistant", Type = "tool_use", + ToolName = toolName, ToolCallId = toolId, + Content = inputEl.ToString(), Tag = cb.Tag + }); ct.ThrowIfCancellationRequested(); @@ -599,13 +609,14 @@ public partial class AnthropicService is_error = isError }); - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = baseIteration + iterations, - Role = "user", Type = "tool_result", - ToolCallId = toolId, - Content = toolResult, IsError = isError, Tag = cb.Tag - }); + if (cb.Persist) + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = baseIteration + iterations, + Role = "user", Type = "tool_result", + ToolCallId = toolId, + Content = toolResult, IsError = isError, Tag = cb.Tag + }); if (cb.SseResponse != null) await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt); diff --git a/Services/AnthropicService.Panel.cs b/Services/AnthropicService.Panel.cs new file mode 100644 index 0000000..d1973a6 --- /dev/null +++ b/Services/AnthropicService.Panel.cs @@ -0,0 +1,266 @@ +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; + + await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode = "fusion" }, sseCt); + + // 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, 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); + 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); + 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, + 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); + + 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). + text = await RunPanelMemberViaCli(member, memberMessages, workCt); + } + else + { + // Anthropic-API-Member: read-only (PlanMode) + NICHT persistieren (Caller persistiert einmal). + 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 }, + 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); + + 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). + /// Sammelt nur den Text, persistiert nichts und sendet keine Text-Deltas + /// (parallele Member → Deltas würden interleaven; der volle Text kommt via panel_member_done). + /// + private async Task RunPanelMemberViaCli(SubAgentContext member, List memberMessages, CancellationToken workCt) + { + 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(); + await _cliService.ChatStreamingAsync( + prompt, + evt => + { + if (evt.Type == CliEventType.TextDelta) + fullText.Append(evt.Text); + return Task.CompletedTask; + }, + 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; + } + } + } +} diff --git a/Services/ChatService.cs b/Services/ChatService.cs index 5c57727..f9cb7e2 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -143,6 +143,22 @@ public class ChatService var finalizerContext = await BuildSubAgentContext( session.FinalizerAgentId, sessionId, workspace, session, user); + // Multi-Modell-Panel (Fusion): Panel-Agenten (Proposer) bauen, falls aktiviert. + // Jeder Panel-Agent ist ein vollwertiger SubAgentContext (Modell/Provider/Key/Prompt/Effort). + var panelContexts = new List(); + if (!string.IsNullOrEmpty(session.MultiModelMode) && session.MultiModelMode != "off" + && !string.IsNullOrWhiteSpace(session.PanelAgentIds)) + { + var panelIds = session.PanelAgentIds + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(); + foreach (var pid in panelIds) + { + var pc = await BuildSubAgentContext(pid, sessionId, workspace, session, user); + if (pc != null) panelContexts.Add(pc); + } + } + if (preparerContext != null) { preparerContext.AllowUserChoice = preparerContext.Agent?.AllowUserChoice ?? false; @@ -204,6 +220,8 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? Preparer = preparerContext, Review = reviewContext, Finalizer = finalizerContext, + MultiModelMode = session.MultiModelMode, + PanelAgents = panelContexts, PlanMode = planMode }; } diff --git a/Services/DatabaseService.Sessions.cs b/Services/DatabaseService.Sessions.cs index c5da5d4..a3ae3eb 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) + 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) { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); @@ -35,12 +35,14 @@ public partial class DatabaseService MaxReviewIterations = maxReviewIterations, FinalizerAgentId = finalizerAgentId, PreparerAgentId = preparerAgentId, + MultiModelMode = multiModelMode, + PanelAgentIds = panelAgentIds, 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, created_at, updated_at) - VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @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, 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) ", session); return session; } @@ -155,6 +157,8 @@ public partial class DatabaseService if (body.TryGetProperty("maxReviewIterations", out var mri)) { sets.Add("max_review_iterations = @maxReviewIterations"); p.Add("maxReviewIterations", mri.GetInt32()); } if (body.TryGetProperty("finalizerAgentId", out var fa)) { sets.Add("finalizer_agent_id = @finalizerAgentId"); p.Add("finalizerAgentId", fa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : fa.GetString()); } 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("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 0c06b67..f664654 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -198,6 +198,8 @@ public partial class DatabaseService TryAddColumn(conn, "messages", "tag", "TEXT"); TryAddColumn(conn, "agents", "effort", "TEXT NOT NULL DEFAULT ''"); TryAddColumn(conn, "chains", "effort", "TEXT NOT NULL DEFAULT ''"); + TryAddColumn(conn, "sessions", "multi_model_mode", "TEXT"); + TryAddColumn(conn, "sessions", "panel_agent_ids", "TEXT"); // 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/Services/OpenAiService.cs b/Services/OpenAiService.cs index 1e46379..e2e6022 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -1087,6 +1087,56 @@ public class OpenAiService /// Non-Streaming Chat für Telegram, Cronjobs und send_to_session. /// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop). /// + /// + /// Minimaler Single-Shot-Completion-Call: KEINE Tools, KEINE Persistenz, KEINE Chain. + /// Für Panel-Member (Fusion) – liefert nur den Antworttext zurück. So leckt nichts + /// ungetaggt in die Worker-History und es entstehen keine Seiteneffekte (Schreiben/Chains). + /// + public async Task CompleteText(string modelId, string systemPromptText, List messages, string apiToken) + { + var resolved = ResolveProviderConfig(modelId, apiToken); + if (resolved == null) + throw new Exception($"Unbekannter Provider: '{ParseModelString(modelId).provider}'"); + var (provider, resolvedModelId, providerConfig) = resolved.Value; + + var apiMessages = BuildApiMessages(systemPromptText, messages); + var client = CreateClient(providerConfig.ApiKey); + var maxTokens = provider == "minimax" ? 32768 : 16384; + + var payload = new Dictionary + { + ["model"] = resolvedModelId, + ["messages"] = apiMessages, + ["stream"] = false, + ["max_tokens"] = maxTokens + }; + if (provider == "nvidia" && IsNvidiaThinkingModel(resolvedModelId)) + payload["chat_template_kwargs"] = new { thinking = true }; + if (provider == "minimax") + { + payload["temperature"] = 1.0; + payload["top_p"] = 0.95; + } + + var jsonPayload = JsonSerializer.Serialize(payload); + var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + using var httpResp = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Post, $"{providerConfig.BaseUrl}/chat/completions") { Content = httpContent }); + var respBody = await httpResp.Content.ReadAsStringAsync(); + if (!httpResp.IsSuccessStatusCode) + throw new Exception($"OpenAI API {(int)httpResp.StatusCode}: {respBody}"); + + var resp = JsonSerializer.Deserialize(respBody); + if (!resp.TryGetProperty("choices", out var choices) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0) + return ""; + var choice = choices[0]; + if (!choice.TryGetProperty("message", out var message) || message.ValueKind != JsonValueKind.Object) + return ""; + if (message.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) + return contentProp.GetString() ?? ""; + return ""; + } + public async Task ChatNonStreaming( ChatContext ctx, List messages, diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 3f04beb..e984853 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -1219,6 +1219,14 @@ border-color: var(--info, #17a2b8); } +.panel-bubble .review-header { + color: var(--accent, #9b6dff); +} + +.panel-bubble { + border-color: var(--accent, #9b6dff); +} + .review-inline-feedback { color: var(--warning); margin-bottom: .4em; diff --git a/wwwroot/index.html b/wwwroot/index.html index 9f6eaeb..da194fe 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -156,6 +156,19 @@
Kein Finalizer-Agent
+ + +
diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 98cd718..8bbdac1 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -128,6 +128,23 @@ function handleStreamEvent(eventType, data) { finalizeReviewBubble('done', data, 'finalizer'); break; + // Multi-Modell-Panel (Fusion): mehrere Member parallel, non-streaming + case 'panel_start': + startPanel(data); + break; + + case 'panel_member_start': + addPanelMemberBubble(data); + break; + + case 'panel_member_done': + finalizePanelMember(data); + break; + + case 'panel_done': + endPanel(data); + break; + case 'auto_context': addAutoContextBubble(data.context); break; diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 7861037..57efb9f 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -230,6 +230,19 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { fragment.appendChild(_renderReviewGroup(tagGroup, archived)); continue; } + + // ── Panel (Fusion): je Member eine eigene Bubble ── + if (msg.tag === 'panel') { + const panelGroup = [msg]; + while (_msgIdx + 1 < msgs.length && msgs[_msgIdx + 1].tag === 'panel') { + _msgIdx++; + panelGroup.push(msgs[_msgIdx]); + } + for (var _pi = 0; _pi < panelGroup.length; _pi++) { + fragment.appendChild(_renderPanelMemberBubble(panelGroup[_pi], archived)); + } + continue; + } switch (msg.type) { case 'text': { const isReviewMsg = _isReviewMessage(msg.content); @@ -467,6 +480,27 @@ function _renderReviewGroup(tagGroup, archived) { return bubble; } +/** + * Renders a single panel member's proposal (tag="panel", content "[Panel: Name]\n...") + * as its own panel bubble – matches the live panel_member bubble. + */ +function _renderPanelMemberBubble(msg, archived) { + var bubble = document.createElement('div'); + bubble.className = 'msg review-bubble panel-bubble' + (archived ? ' archived' : ''); + var content = msg.content || ''; + var name = null; + var nameMatch = content.match(/^\[Panel: ([^\]]+)\]/); + if (nameMatch) name = nameMatch[1]; + bubble.appendChild(_createSubAgentHeader('panel', name)); + var text = content.replace(/^\[Panel: [^\]]+\]\s*/, ''); + var div = document.createElement('div'); + div.className = 'review-content markdown'; + div.innerHTML = renderMarkdown(text || '_(keine Antwort)_'); + parseFluentEmojis(div); + bubble.appendChild(div); + return bubble; +} + function _addOpenClawBadge(div) { var badge = document.createElement('span'); badge.className = 'openclaw-badge'; diff --git a/wwwroot/js/modals.js b/wwwroot/js/modals.js index 0bd74d0..8242343 100644 --- a/wwwroot/js/modals.js +++ b/wwwroot/js/modals.js @@ -192,6 +192,61 @@ function _openPickerModal(title, items, currentId, onSelect, options) { }); } +// ── Multi-Select Picker Modal ── +// Wie _openPickerModal, aber Mehrfachauswahl: Karten togglen ihren selected-Zustand, +// ein "Übernehmen"-Button bestätigt. onConfirm bekommt das Array der gewählten _pickerId. + +function _openMultiPickerModal(title, items, selectedIds, onConfirm, options) { + options = options || {}; + var selected = new Set(selectedIds || []); + + var overlay = document.createElement('div'); + overlay.className = 'agent-picker-overlay'; + + var modal = document.createElement('div'); + modal.className = 'agent-picker-modal'; + + var header = document.createElement('div'); + header.className = 'agent-picker-header'; + header.innerHTML = '' + esc(title) + ''; + modal.appendChild(header); + + var body = document.createElement('div'); + body.className = 'agent-picker-body'; + + for (var i = 0; i < items.length; i++) { + (function(item) { + var cardEl = options.buildCard(item); + cardEl.classList.add('agent-picker-item'); + if (selected.has(item._pickerId)) cardEl.classList.add('selected'); + cardEl.addEventListener('click', function() { + if (selected.has(item._pickerId)) { selected.delete(item._pickerId); cardEl.classList.remove('selected'); } + else { selected.add(item._pickerId); cardEl.classList.add('selected'); } + }); + body.appendChild(cardEl); + })(items[i]); + } + modal.appendChild(body); + + var footer = document.createElement('div'); + footer.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;padding:12px;border-top:1px solid var(--f2-border)'; + var confirmBtn = document.createElement('button'); + confirmBtn.className = 'add-cmd-btn'; + confirmBtn.textContent = 'Übernehmen'; + confirmBtn.addEventListener('click', function() { + onConfirm(Array.from(selected)); + overlay.remove(); + }); + footer.appendChild(confirmBtn); + modal.appendChild(footer); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + header.querySelector('.agent-picker-close').addEventListener('click', function() { overlay.remove(); }); + overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); }); +} + // ── Telegram Bot Picker Modal ── function openTelegramBotPickerModal(bots, currentBotId, onSelect) { diff --git a/wwwroot/js/pages/session-page.js b/wwwroot/js/pages/session-page.js index 0930ee5..d7e835f 100644 --- a/wwwroot/js/pages/session-page.js +++ b/wwwroot/js/pages/session-page.js @@ -29,6 +29,10 @@ registerPage('session', { const maxReviewIterationsEl = document.getElementById('maxReviewIterations'); const finalizerAgentSelect = document.getElementById('finalizerAgentSelect'); const finalizerAgentSelectCard = document.getElementById('finalizerAgentSelectCard'); + const panelModeEl = document.getElementById('multiModelMode'); + const panelAgentsRow = document.getElementById('panelAgentsRow'); + const panelAgentsCard = document.getElementById('panelAgentsCard'); + const panelAgentIdsEl = document.getElementById('panelAgentIds'); const imagePreview = document.getElementById('sessionImagePreview'); const saveBtn = document.getElementById('sessionSaveBtn'); const cancelBtn = document.getElementById('sessionCancelBtn'); @@ -126,6 +130,38 @@ registerPage('session', { updateReviewVisibility(); setupAgentPicker(finalizerAgentSelectCard, finalizerAgentSelect, session && session.finalizerAgentId, 'fa-flag-checkered', 'Kein Finalizer-Agent'); + // Multi-Modell-Panel (Fusion): Modus + Mehrfach-Agenten-Picker + function renderPanelInCard() { + var ids = (panelAgentIdsEl.value || '').split(',').map(function(s){ return s.trim(); }).filter(Boolean); + if (ids.length === 0) { + panelAgentsCard.innerHTML = '
Keine Panel-Agenten
'; + return; + } + var chips = ids.map(function(id) { + var a = agentMap[id]; + var label = a ? a.name : id.slice(0, 8); + var model = a ? ((typeof prettyModelName === 'function') ? prettyModelName(a.model) : a.model) : ''; + return '' + esc(label) + (model ? ' · ' + esc(model) : '') + ''; + }).join(' '); + panelAgentsCard.innerHTML = '
' + chips + '
' + + ''; + } + function updatePanelVisibility() { + panelAgentsRow.hidden = (panelModeEl.value === 'off'); + } + panelModeEl.value = (session && session.multiModelMode) || 'off'; + panelAgentIdsEl.value = (session && session.panelAgentIds) || ''; + renderPanelInCard(); + updatePanelVisibility(); + panelModeEl.addEventListener('change', updatePanelVisibility); + panelAgentsCard.addEventListener('click', function() { + var currentIds = (panelAgentIdsEl.value || '').split(',').map(function(s){ return s.trim(); }).filter(Boolean); + openMultiAgentPickerModal(agents, agentSkillsMap, currentIds, function(newIds) { + panelAgentIdsEl.value = newIds.join(','); + renderPanelInCard(); + }); + }); + // User-Zuweisung (jeder Besitzer, nur für bestehende Sessions) if (!isNew) { initSessionUserPicker(ownerRow, userSelectCard, userSelect, session && session.userId); @@ -177,6 +213,8 @@ registerPage('session', { maxReviewIterations: parseInt(maxReviewIterationsEl.value) || 3, finalizerAgentId: finalizerAgentSelect.value || null, preparerAgentId: preparerAgentSelect.value || null, + multiModelMode: panelModeEl.value || 'off', + panelAgentIds: panelAgentIdsEl.value || null, context: _sessionContextMde.value().trim() || null, workspace: workspaceEl.value.trim() || null, customCommands: commands, @@ -247,6 +285,16 @@ function openAgentPickerModal(agents, agentSkillsMap, currentId, onSelect) { }); } +function openMultiAgentPickerModal(agents, agentSkillsMap, selectedIds, onConfirm) { + const items = agents.map(a => { + a._pickerId = a.agentId; + return a; + }); + _openMultiPickerModal('Panel-Agenten auswählen', items, selectedIds, onConfirm, { + buildCard: (a) => buildAgentCard(a, agentSkillsMap[a.agentId] || []) + }); +} + function initSessionTelegram(sessionId, session) { var botSelectCard = document.getElementById('telegramBotSelectCard'); var botSelect = document.getElementById('telegramBotSelect'); diff --git a/wwwroot/js/stream-ui.js b/wwwroot/js/stream-ui.js index 474dbc1..52ed6da 100644 --- a/wwwroot/js/stream-ui.js +++ b/wwwroot/js/stream-ui.js @@ -205,3 +205,42 @@ function finalizeReviewBubble(type, data, agentType) { _currentReviewToolBox = null; scrollDown(); } + +// ── Panel Bubbles (Fusion: mehrere Member parallel, non-streaming) ── +// Member liefern ihren Text gesammelt bei panel_member_done → pro Member eine eigene +// Bubble (keyed by index), kein Konflikt mit dem Single-_reviewBubble-State. + +let _panelBubbles = {}; + +function startPanel(data) { + endStreamBubble(); + _panelBubbles = {}; +} + +function addPanelMemberBubble(data) { + var bubble = document.createElement('div'); + bubble.className = 'msg review-bubble panel-bubble streamed'; + var model = (typeof prettyModelName === 'function') ? prettyModelName(data.model) : data.model; + bubble.appendChild(_createSubAgentHeader('panel', data.name, { model: model })); + var content = document.createElement('div'); + content.className = 'review-content markdown'; + content.innerHTML = ' denkt nach…'; + bubble.appendChild(content); + bubble._panelContent = content; + _panelBubbles[data.index] = bubble; + chatEl().appendChild(bubble); + scrollDown(); +} + +function finalizePanelMember(data) { + var bubble = _panelBubbles[data.index]; + if (!bubble) return; + var text = (data.text && data.text.trim()) ? data.text : '_(keine Antwort)_'; + bubble._panelContent.innerHTML = renderMarkdown(text); + delete _panelBubbles[data.index]; + scrollDown(); +} + +function endPanel(data) { + _panelBubbles = {}; +} diff --git a/wwwroot/js/ui-helpers.js b/wwwroot/js/ui-helpers.js index 1d49dbe..6519e19 100644 --- a/wwwroot/js/ui-helpers.js +++ b/wwwroot/js/ui-helpers.js @@ -244,7 +244,7 @@ function removeArchiveLoadButton() { // ── Review/Sub-Agent Helpers ── function _isReviewMessage(content) { - return content && (content.startsWith('[Review:') || content.startsWith('[Review-Feedback') || content.startsWith('[Finalizer:') || content.startsWith('[Preparer:')); + return content && (content.startsWith('[Review:') || content.startsWith('[Review-Feedback') || content.startsWith('[Finalizer:') || content.startsWith('[Preparer:') || content.startsWith('[Panel:')); } /** @@ -254,6 +254,7 @@ function _isReviewMessage(content) { function _getSubAgentMeta(agentType) { if (agentType === 'preparer') return { icon: 'fa-filter', defaultName: 'Vorbereiter-Agent' }; if (agentType === 'finalizer') return { icon: 'fa-flag-checkered', defaultName: 'Finalizer-Agent' }; + if (agentType === 'panel') return { icon: 'fa-users', defaultName: 'Panel-Agent' }; return { icon: 'fa-check-circle-o', defaultName: 'Review-Agent' }; } @@ -271,6 +272,9 @@ function _createSubAgentHeader(agentType, agentName, extra) { if (agentType === 'review' && extra && extra.round) { html += ' Runde ' + extra.round + '/' + extra.maxRounds + ''; } + if (agentType === 'panel' && extra && extra.model) { + html += ' ' + esc(extra.model) + ''; + } header.innerHTML = html; return header; }