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 <panel_responses> 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b23eba32b4
commit
268f11fa6d
20 files changed
+647
-35
No files matched your search
+4
-2
@@ -348,7 +348,7 @@ TelegramBot (1) ─── (*) Session (via telegram_bot_id)
|
|||||||
| `users` | Benutzer (username, password_hash, display_name, is_admin, persona, settings, default_path) |
|
| `users` | Benutzer (username, password_hash, display_name, is_admin, persona, settings, default_path) |
|
||||||
| `tokens` | Auth-Tokens (token → user_id) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `attachments` | Datei-Anhänge (base64, image/document) |
|
||||||
@@ -392,7 +392,7 @@ TelegramBot (1) ─── (*) Session (via telegram_bot_id)
|
|||||||
- `Iteration` – Welche Tool-Loop-Iteration
|
- `Iteration` – Welche Tool-Loop-Iteration
|
||||||
- `AttachmentCount` – Anzahl angehängter Dateien
|
- `AttachmentCount` – Anzahl angehängter Dateien
|
||||||
- `AttachmentNames` – JSON-Array mit Dateinamen
|
- `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)
|
- `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)
|
- `ThoughtSignature` – Gemini thought_signature (für Gemini-Tool-Calls, wird im nächsten Request zurückgesendet)
|
||||||
- `DeletedOn` – Soft-Delete Zeitstempel
|
- `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**.
|
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.
|
||||||
|
|
||||||
### Architektur
|
### Architektur
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+13
-1
@@ -88,7 +88,7 @@ User 1──* TelegramBot 1──* Session (via telegram_bot_id)
|
|||||||
| `users` | Login, DisplayName, IsAdmin, Persona, Settings (JSON), DefaultPath |
|
| `users` | Login, DisplayName, IsAdmin, Persona, Settings (JSON), DefaultPath |
|
||||||
| `tokens` | Auth-Tokens → User |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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]`
|
- 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)
|
- 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 `<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.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
**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 Sources:**
|
||||||
- `web_search_result`-Messages in DB gespeichert (Sources als JSON-Array, leere werden übersprungen)
|
- `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)
|
- History + Shared-Page: Tool-Box mit klickbaren Source-Links (Domain + Title)
|
||||||
|
|||||||
@@ -230,6 +230,26 @@ public static class ChatEndpoint
|
|||||||
|
|
||||||
try
|
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))
|
if (!string.IsNullOrEmpty(ctx.Provider))
|
||||||
{
|
{
|
||||||
// OpenAI-kompatibler Provider – Key des Users für diesen Agent verwenden
|
// OpenAI-kompatibler Provider – Key des Users für diesen Agent verwenden
|
||||||
|
|||||||
@@ -146,8 +146,10 @@ public static class SessionEndpoint
|
|||||||
var maxReviewIterations = body.TryGetProperty("maxReviewIterations", out var mri) ? mri.GetInt32() : 3;
|
var maxReviewIterations = body.TryGetProperty("maxReviewIterations", out var mri) ? mri.GetInt32() : 3;
|
||||||
var finalizerAgentId = body.TryGetProperty("finalizerAgentId", out var fa) ? fa.GetString() : null;
|
var finalizerAgentId = body.TryGetProperty("finalizerAgentId", out var fa) ? fa.GetString() : null;
|
||||||
var preparerAgentId = body.TryGetProperty("preparerAgentId", out var pra) ? pra.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);
|
await ctx.Response.WriteAsJsonAsync(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ public class ChatContext
|
|||||||
|
|
||||||
// Finalizer-Agent
|
// Finalizer-Agent
|
||||||
public SubAgentContext Finalizer { get; set; }
|
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<SubAgentContext> PanelAgents { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SubAgentContext
|
public class SubAgentContext
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public class SessionRecord
|
|||||||
public int MaxReviewIterations { get; set; } = 3;
|
public int MaxReviewIterations { get; set; } = 3;
|
||||||
public string FinalizerAgentId { get; set; }
|
public string FinalizerAgentId { get; set; }
|
||||||
public string PreparerAgentId { 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 UnityMcpUrl { get; set; }
|
||||||
public string UnityMcpToken { get; set; }
|
public string UnityMcpToken { get; set; }
|
||||||
public string CreatedAt { get; set; }
|
public string CreatedAt { get; set; }
|
||||||
|
|||||||
@@ -422,6 +422,11 @@ public partial class AnthropicService
|
|||||||
/// <summary>Ob Text-Embeddings in die Queue geschrieben werden.</summary>
|
/// <summary>Ob Text-Embeddings in die Queue geschrieben werden.</summary>
|
||||||
public bool EnqueueEmbeddings { get; set; }
|
public bool EnqueueEmbeddings { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public bool Persist { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optionaler Tool-Executor. Parameter: (toolName, input, ct) → result.
|
/// Optionaler Tool-Executor. Parameter: (toolName, input, ct) → result.
|
||||||
/// Bei Fehler Exception werfen. Wenn null, wird Standard-Executor verwendet.
|
/// 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 errBody = await httpResp.Content.ReadAsStringAsync(ct);
|
||||||
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
|
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
|
||||||
await _db.SaveMessage(new MessageRecord
|
if (cb.Persist)
|
||||||
{
|
await _db.SaveMessage(new MessageRecord
|
||||||
ChainId = chainId, Iteration = baseIteration + iterations,
|
{
|
||||||
Role = "system", Type = "error",
|
ChainId = chainId, Iteration = baseIteration + iterations,
|
||||||
Content = errMsg, IsError = true, Tag = cb.Tag
|
Role = "system", Type = "error",
|
||||||
});
|
Content = errMsg, IsError = true, Tag = cb.Tag
|
||||||
|
});
|
||||||
if (cb.SseResponse != null)
|
if (cb.SseResponse != null)
|
||||||
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { error = errMsg }, cb.SseCt);
|
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { error = errMsg }, cb.SseCt);
|
||||||
break;
|
break;
|
||||||
@@ -542,14 +548,17 @@ public partial class AnthropicService
|
|||||||
finalText.Append(text);
|
finalText.Append(text);
|
||||||
assistantContent.Add(new { type = "text", text });
|
assistantContent.Add(new { type = "text", text });
|
||||||
|
|
||||||
var msgId = await _db.SaveMessage(new MessageRecord
|
if (cb.Persist)
|
||||||
{
|
{
|
||||||
ChainId = chainId, Iteration = baseIteration + iterations,
|
var msgId = await _db.SaveMessage(new MessageRecord
|
||||||
Role = "assistant", Type = "text",
|
{
|
||||||
Content = text, Tag = cb.Tag
|
ChainId = chainId, Iteration = baseIteration + iterations,
|
||||||
});
|
Role = "assistant", Type = "text",
|
||||||
if (cb.EnqueueEmbeddings)
|
Content = text, Tag = cb.Tag
|
||||||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text);
|
});
|
||||||
|
if (cb.EnqueueEmbeddings)
|
||||||
|
_embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text);
|
||||||
|
}
|
||||||
if (cb.SseResponse != null)
|
if (cb.SseResponse != null)
|
||||||
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { text, agentName = cb.AgentName }, cb.SseCt);
|
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 });
|
assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = inputEl });
|
||||||
|
|
||||||
await _db.SaveMessage(new MessageRecord
|
if (cb.Persist)
|
||||||
{
|
await _db.SaveMessage(new MessageRecord
|
||||||
ChainId = chainId, Iteration = baseIteration + iterations,
|
{
|
||||||
Role = "assistant", Type = "tool_use",
|
ChainId = chainId, Iteration = baseIteration + iterations,
|
||||||
ToolName = toolName, ToolCallId = toolId,
|
Role = "assistant", Type = "tool_use",
|
||||||
Content = inputEl.ToString(), Tag = cb.Tag
|
ToolName = toolName, ToolCallId = toolId,
|
||||||
});
|
Content = inputEl.ToString(), Tag = cb.Tag
|
||||||
|
});
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -599,13 +609,14 @@ public partial class AnthropicService
|
|||||||
is_error = isError
|
is_error = isError
|
||||||
});
|
});
|
||||||
|
|
||||||
await _db.SaveMessage(new MessageRecord
|
if (cb.Persist)
|
||||||
{
|
await _db.SaveMessage(new MessageRecord
|
||||||
ChainId = chainId, Iteration = baseIteration + iterations,
|
{
|
||||||
Role = "user", Type = "tool_result",
|
ChainId = chainId, Iteration = baseIteration + iterations,
|
||||||
ToolCallId = toolId,
|
Role = "user", Type = "tool_result",
|
||||||
Content = toolResult, IsError = isError, Tag = cb.Tag
|
ToolCallId = toolId,
|
||||||
});
|
Content = toolResult, IsError = isError, Tag = cb.Tag
|
||||||
|
});
|
||||||
|
|
||||||
if (cb.SseResponse != null)
|
if (cb.SseResponse != null)
|
||||||
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt);
|
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt);
|
||||||
|
|||||||
@@ -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
|
||||||
|
// <panel_responses>-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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// (0 = nichts injiziert → Worker antwortet regulär).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> RunPanel(
|
||||||
|
HttpResponse response,
|
||||||
|
List<SubAgentContext> panel,
|
||||||
|
List<object> 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<Task<PanelMemberResult>>();
|
||||||
|
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("<panel_responses>");
|
||||||
|
foreach (var r in ordered)
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"### Antwort von {r.Name} ({r.Model}):");
|
||||||
|
sb.AppendLine(r.Text.Trim());
|
||||||
|
}
|
||||||
|
sb.AppendLine("</panel_responses>");
|
||||||
|
|
||||||
|
MergePanelIntoLastUserText(workerMessages, sb.ToString());
|
||||||
|
|
||||||
|
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = ordered.Count }, sseCt);
|
||||||
|
return ordered.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<PanelMemberResult> RunPanelMember(
|
||||||
|
HttpResponse response,
|
||||||
|
SubAgentContext member,
|
||||||
|
List<object> 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<object>(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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> RunPanelMemberViaCli(SubAgentContext member, List<object> 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 = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extrahiert den System-Prompt-Text aus den List<object>-Blöcken eines SubAgentContext.</summary>
|
||||||
|
private static string ExtractSystemPromptText(List<object> systemPrompt)
|
||||||
|
{
|
||||||
|
if (systemPrompt == null) return "";
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var block in systemPrompt)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(block);
|
||||||
|
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||||||
|
if (el.TryGetProperty("text", out var t))
|
||||||
|
parts.Add(t.GetString() ?? "");
|
||||||
|
}
|
||||||
|
return string.Join("\n\n", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static void MergePanelIntoLastUserText(List<object> messages, string panelBlock)
|
||||||
|
{
|
||||||
|
for (int i = messages.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (messages[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)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < contentList.Count; j++)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(contentList[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() ?? "" : "";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,22 @@ public class ChatService
|
|||||||
var finalizerContext = await BuildSubAgentContext(
|
var finalizerContext = await BuildSubAgentContext(
|
||||||
session.FinalizerAgentId, sessionId, workspace, session, user);
|
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<SubAgentContext>();
|
||||||
|
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)
|
if (preparerContext != null)
|
||||||
{
|
{
|
||||||
preparerContext.AllowUserChoice = preparerContext.Agent?.AllowUserChoice ?? false;
|
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,
|
Preparer = preparerContext,
|
||||||
Review = reviewContext,
|
Review = reviewContext,
|
||||||
Finalizer = finalizerContext,
|
Finalizer = finalizerContext,
|
||||||
|
MultiModelMode = session.MultiModelMode,
|
||||||
|
PanelAgents = panelContexts,
|
||||||
PlanMode = planMode
|
PlanMode = planMode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public partial class DatabaseService
|
|||||||
{
|
{
|
||||||
// ── Sessions ──
|
// ── 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)
|
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)
|
||||||
{
|
{
|
||||||
using var conn = CreateConnection();
|
using var conn = CreateConnection();
|
||||||
var now = DateTime.UtcNow.ToString("o");
|
var now = DateTime.UtcNow.ToString("o");
|
||||||
@@ -35,12 +35,14 @@ public partial class DatabaseService
|
|||||||
MaxReviewIterations = maxReviewIterations,
|
MaxReviewIterations = maxReviewIterations,
|
||||||
FinalizerAgentId = finalizerAgentId,
|
FinalizerAgentId = finalizerAgentId,
|
||||||
PreparerAgentId = preparerAgentId,
|
PreparerAgentId = preparerAgentId,
|
||||||
|
MultiModelMode = multiModelMode,
|
||||||
|
PanelAgentIds = panelAgentIds,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
UpdatedAt = now
|
UpdatedAt = now
|
||||||
};
|
};
|
||||||
await conn.ExecuteAsync(@"
|
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)
|
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, @CreatedAt, @UpdatedAt)
|
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @MultiModelMode, @PanelAgentIds, @CreatedAt, @UpdatedAt)
|
||||||
", session);
|
", session);
|
||||||
return 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("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("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("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("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()); }
|
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()); }
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ public partial class DatabaseService
|
|||||||
TryAddColumn(conn, "messages", "tag", "TEXT");
|
TryAddColumn(conn, "messages", "tag", "TEXT");
|
||||||
TryAddColumn(conn, "agents", "effort", "TEXT NOT NULL DEFAULT ''");
|
TryAddColumn(conn, "agents", "effort", "TEXT NOT NULL DEFAULT ''");
|
||||||
TryAddColumn(conn, "chains", "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
|
// 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.
|
// bei Opus 4.6/4.7/4.8 + Sonnet 4.6 – kein "[1m]"-Suffix mehr nötig.
|
||||||
|
|||||||
@@ -1087,6 +1087,56 @@ public class OpenAiService
|
|||||||
/// Non-Streaming Chat für Telegram, Cronjobs und send_to_session.
|
/// Non-Streaming Chat für Telegram, Cronjobs und send_to_session.
|
||||||
/// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop).
|
/// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> CompleteText(string modelId, string systemPromptText, List<object> 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<string, object>
|
||||||
|
{
|
||||||
|
["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<JsonElement>(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<string> ChatNonStreaming(
|
public async Task<string> ChatNonStreaming(
|
||||||
ChatContext ctx,
|
ChatContext ctx,
|
||||||
List<object> messages,
|
List<object> messages,
|
||||||
|
|||||||
@@ -1219,6 +1219,14 @@
|
|||||||
border-color: var(--info, #17a2b8);
|
border-color: var(--info, #17a2b8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-bubble .review-header {
|
||||||
|
color: var(--accent, #9b6dff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-bubble {
|
||||||
|
border-color: var(--accent, #9b6dff);
|
||||||
|
}
|
||||||
|
|
||||||
.review-inline-feedback {
|
.review-inline-feedback {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
margin-bottom: .4em;
|
margin-bottom: .4em;
|
||||||
|
|||||||
@@ -156,6 +156,19 @@
|
|||||||
<div class="agent-select-empty"><i class="fa fa-flag-checkered"></i> Kein Finalizer-Agent</div>
|
<div class="agent-select-empty"><i class="fa fa-flag-checkered"></i> Kein Finalizer-Agent</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="finalizerAgentSelect" value="">
|
<input type="hidden" id="finalizerAgentSelect" value="">
|
||||||
|
<label>Multi-Modell-Panel <small>(Fusion)</small></label>
|
||||||
|
<select id="multiModelMode">
|
||||||
|
<option value="off">Aus</option>
|
||||||
|
<option value="fusion">Fusion – Panel antwortet parallel, Session-Agent synthetisiert</option>
|
||||||
|
</select>
|
||||||
|
<div id="panelAgentsRow" hidden>
|
||||||
|
<label>Panel-Agenten <small>(antworten parallel zur Anfrage)</small></label>
|
||||||
|
<div id="panelAgentsCard" class="agent-select-card">
|
||||||
|
<div class="agent-select-empty"><i class="fa fa-users"></i> Keine Panel-Agenten</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
<label>Benutzerdefinierte Befehle</label>
|
<label>Benutzerdefinierte Befehle</label>
|
||||||
<div class="custom-commands" id="customCommandsList"></div>
|
<div class="custom-commands" id="customCommandsList"></div>
|
||||||
<button type="button" id="addCommandBtn" class="add-cmd-btn">+ Befehl hinzufügen</button>
|
<button type="button" id="addCommandBtn" class="add-cmd-btn">+ Befehl hinzufügen</button>
|
||||||
|
|||||||
@@ -128,6 +128,23 @@ function handleStreamEvent(eventType, data) {
|
|||||||
finalizeReviewBubble('done', data, 'finalizer');
|
finalizeReviewBubble('done', data, 'finalizer');
|
||||||
break;
|
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':
|
case 'auto_context':
|
||||||
addAutoContextBubble(data.context);
|
addAutoContextBubble(data.context);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -230,6 +230,19 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
|||||||
fragment.appendChild(_renderReviewGroup(tagGroup, archived));
|
fragment.appendChild(_renderReviewGroup(tagGroup, archived));
|
||||||
continue;
|
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) {
|
switch (msg.type) {
|
||||||
case 'text': {
|
case 'text': {
|
||||||
const isReviewMsg = _isReviewMessage(msg.content);
|
const isReviewMsg = _isReviewMessage(msg.content);
|
||||||
@@ -467,6 +480,27 @@ function _renderReviewGroup(tagGroup, archived) {
|
|||||||
return bubble;
|
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) {
|
function _addOpenClawBadge(div) {
|
||||||
var badge = document.createElement('span');
|
var badge = document.createElement('span');
|
||||||
badge.className = 'openclaw-badge';
|
badge.className = 'openclaw-badge';
|
||||||
|
|||||||
@@ -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 = '<span>' + esc(title) + '</span><button class="icon-btn agent-picker-close"><i class="fa fa-times"></i></button>';
|
||||||
|
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 ──
|
// ── Telegram Bot Picker Modal ──
|
||||||
|
|
||||||
function openTelegramBotPickerModal(bots, currentBotId, onSelect) {
|
function openTelegramBotPickerModal(bots, currentBotId, onSelect) {
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ registerPage('session', {
|
|||||||
const maxReviewIterationsEl = document.getElementById('maxReviewIterations');
|
const maxReviewIterationsEl = document.getElementById('maxReviewIterations');
|
||||||
const finalizerAgentSelect = document.getElementById('finalizerAgentSelect');
|
const finalizerAgentSelect = document.getElementById('finalizerAgentSelect');
|
||||||
const finalizerAgentSelectCard = document.getElementById('finalizerAgentSelectCard');
|
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 imagePreview = document.getElementById('sessionImagePreview');
|
||||||
const saveBtn = document.getElementById('sessionSaveBtn');
|
const saveBtn = document.getElementById('sessionSaveBtn');
|
||||||
const cancelBtn = document.getElementById('sessionCancelBtn');
|
const cancelBtn = document.getElementById('sessionCancelBtn');
|
||||||
@@ -126,6 +130,38 @@ registerPage('session', {
|
|||||||
updateReviewVisibility();
|
updateReviewVisibility();
|
||||||
setupAgentPicker(finalizerAgentSelectCard, finalizerAgentSelect, session && session.finalizerAgentId, 'fa-flag-checkered', 'Kein Finalizer-Agent');
|
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 = '<div class="agent-select-empty"><i class="fa fa-users"></i> Keine Panel-Agenten</div>';
|
||||||
|
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 '<span class="badge">' + esc(label) + (model ? ' · ' + esc(model) : '') + '</span>';
|
||||||
|
}).join(' ');
|
||||||
|
panelAgentsCard.innerHTML = '<div style="display:flex;flex-wrap:wrap;gap:4px;align-items:center">' + chips + '</div>'
|
||||||
|
+ '<span class="agent-select-change"><i class="fa fa-pencil"></i></span>';
|
||||||
|
}
|
||||||
|
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)
|
// User-Zuweisung (jeder Besitzer, nur für bestehende Sessions)
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
initSessionUserPicker(ownerRow, userSelectCard, userSelect, session && session.userId);
|
initSessionUserPicker(ownerRow, userSelectCard, userSelect, session && session.userId);
|
||||||
@@ -177,6 +213,8 @@ registerPage('session', {
|
|||||||
maxReviewIterations: parseInt(maxReviewIterationsEl.value) || 3,
|
maxReviewIterations: parseInt(maxReviewIterationsEl.value) || 3,
|
||||||
finalizerAgentId: finalizerAgentSelect.value || null,
|
finalizerAgentId: finalizerAgentSelect.value || null,
|
||||||
preparerAgentId: preparerAgentSelect.value || null,
|
preparerAgentId: preparerAgentSelect.value || null,
|
||||||
|
multiModelMode: panelModeEl.value || 'off',
|
||||||
|
panelAgentIds: panelAgentIdsEl.value || null,
|
||||||
context: _sessionContextMde.value().trim() || null,
|
context: _sessionContextMde.value().trim() || null,
|
||||||
workspace: workspaceEl.value.trim() || null,
|
workspace: workspaceEl.value.trim() || null,
|
||||||
customCommands: commands,
|
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) {
|
function initSessionTelegram(sessionId, session) {
|
||||||
var botSelectCard = document.getElementById('telegramBotSelectCard');
|
var botSelectCard = document.getElementById('telegramBotSelectCard');
|
||||||
var botSelect = document.getElementById('telegramBotSelect');
|
var botSelect = document.getElementById('telegramBotSelect');
|
||||||
|
|||||||
@@ -205,3 +205,42 @@ function finalizeReviewBubble(type, data, agentType) {
|
|||||||
_currentReviewToolBox = null;
|
_currentReviewToolBox = null;
|
||||||
scrollDown();
|
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 = '<span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span> 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 = {};
|
||||||
|
}
|
||||||
@@ -244,7 +244,7 @@ function removeArchiveLoadButton() {
|
|||||||
// ── Review/Sub-Agent Helpers ──
|
// ── Review/Sub-Agent Helpers ──
|
||||||
|
|
||||||
function _isReviewMessage(content) {
|
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) {
|
function _getSubAgentMeta(agentType) {
|
||||||
if (agentType === 'preparer') return { icon: 'fa-filter', defaultName: 'Vorbereiter-Agent' };
|
if (agentType === 'preparer') return { icon: 'fa-filter', defaultName: 'Vorbereiter-Agent' };
|
||||||
if (agentType === 'finalizer') return { icon: 'fa-flag-checkered', defaultName: 'Finalizer-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' };
|
return { icon: 'fa-check-circle-o', defaultName: 'Review-Agent' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +272,9 @@ function _createSubAgentHeader(agentType, agentName, extra) {
|
|||||||
if (agentType === 'review' && extra && extra.round) {
|
if (agentType === 'review' && extra && extra.round) {
|
||||||
html += ' <span class="review-round">Runde ' + extra.round + '/' + extra.maxRounds + '</span>';
|
html += ' <span class="review-round">Runde ' + extra.round + '/' + extra.maxRounds + '</span>';
|
||||||
}
|
}
|
||||||
|
if (agentType === 'panel' && extra && extra.model) {
|
||||||
|
html += ' <span class="review-round">' + esc(extra.model) + '</span>';
|
||||||
|
}
|
||||||
header.innerHTML = html;
|
header.innerHTML = html;
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user