diff --git a/Services/AnthropicService.Panel.cs b/Services/AnthropicService.Panel.cs index 72ba21f..b9864fa 100644 --- a/Services/AnthropicService.Panel.cs +++ b/Services/AnthropicService.Panel.cs @@ -21,7 +21,7 @@ public partial class AnthropicService // // MoA (Mixture-of-Agents): layers>1 → mehrere Proposer-Runden; jede Runde k>1 sieht // die Antworten der Vorrunde () und verfeinert. Fusion = layers==1. - // Nur die FINALE Runde wird persistiert (Zwischen-Runden sind live-only). + // JEDE Runde wird persistiert (mit Layer-Index im JSON-Transkript → F5-sicher, Runden-Divider beim Reload). // // Member laufen READ-ONLY (Anthropic: PlanMode-Filter, CLI: --permission-mode plan) // bzw. tool-los (OpenAI), damit parallele Member keine Dateien gleichzeitig schreiben. @@ -39,8 +39,8 @@ public partial class AnthropicService /// /// Führt das Panel aus (Fusion = 1 Runde, MoA = Runden): pro Runde - /// alle Member parallel; ab Runde 2 sieht jeder Member die Antworten der Vorrunde. Die FINALE - /// Runde wird persistiert (Tag="panel"); ihr Synthese-Block wird in die letzte User-Message von + /// alle Member parallel; ab Runde 2 sieht jeder Member die Antworten der Vorrunde. JEDE Runde + /// wird persistiert (Tag="panel", mit Layer-Index); der Synthese-Block der finalen Runde wird in die letzte User-Message von /// gemergt. Danach streamt der Worker als Synthesizer ganz /// normal weiter. Gibt die Anzahl beigetragener Member der finalen Runde zurück /// (0 = nichts injiziert → Worker antwortet regulär). @@ -72,7 +72,6 @@ public partial class AnthropicService List prev = null; for (int layer = 1; layer <= layers; layer++) { - bool isFinalLayer = layer == layers; await SseHelper.TryWriteSse(response, "panel_layer_start", new { layer, total = layers }, sseCt, sseGate); string prevBlock = prev == null ? null : BuildPreviousLayerBlock(prev, layer, layers); @@ -88,7 +87,7 @@ public partial class AnthropicService tasks.Add(Task.Run(async () => { await sem.WaitAsync(workCt); - try { return await RunPanelMember(response, member, workerMessages, index, lyr, layers, prevBlock, isFinalLayer, chainId, sessionId, apiToken, sseGate, workCt, sseCt); } + try { return await RunPanelMember(response, member, workerMessages, index, lyr, layers, prevBlock, chainId, sessionId, apiToken, sseGate, workCt, sseCt); } finally { sem.Release(); } }, workCt)); } @@ -148,7 +147,6 @@ public partial class AnthropicService int layer, int totalLayers, string previousLayerBlock, - bool persist, string chainId, string sessionId, string apiToken, @@ -215,29 +213,27 @@ public partial class AnthropicService text = (text ?? "").Trim(); // Member-Transkript als EINE strukturierte Message persistieren (Tag="panel" → aus - // Worker-History gefiltert, Iteration=index+1 → stabile Reihenfolge). Content = JSON - // {name, model, segments:[{t:"text"|"tool",...}]} → der Reload stellt Text UND Tool-Boxen - // wieder her (genau wie live). Fällt auf ein einzelnes Text-Segment zurück (OpenAI-Member, - // Fehler oder wenn nichts gestreamt wurde). Nur die FINALE Runde (MoA-Zwischenrunden sind - // live-only und überleben kein F5). - if (persist) + // Worker-History gefiltert). Content = JSON {name, model, layer, totalLayers, + // segments:[{t:"text"|"tool",...}]} → der Reload stellt Text UND Tool-Boxen wieder her + // (genau wie live) und gruppiert bei MoA nach Runden mit Divider. Fällt auf ein einzelnes + // Text-Segment zurück (OpenAI-Member, Fehler oder wenn nichts gestreamt wurde). JEDE Runde + // wird persistiert (Iteration=layer*100+index+1 → stabile, layer-aware Reihenfolge) → MoA- + // Zwischenrunden überleben F5. + if (segments == null || segments.Count == 0) { - if (segments == null || segments.Count == 0) + segments = new List(); + if (!string.IsNullOrEmpty(text)) + segments.Add(new Dictionary { ["t"] = "text", ["v"] = text }); + } + if (segments.Count > 0) + { + var content = JsonSerializer.Serialize(new { name, model = member.ModelId, layer, totalLayers, segments }); + await _db.SaveMessage(new MessageRecord { - segments = new List(); - if (!string.IsNullOrEmpty(text)) - segments.Add(new Dictionary { ["t"] = "text", ["v"] = text }); - } - if (segments.Count > 0) - { - var content = JsonSerializer.Serialize(new { name, model = member.ModelId, segments }); - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = index + 1, - Role = "assistant", Type = "text", - Content = content, Tag = "panel" - }); - } + ChainId = chainId, Iteration = layer * 100 + index + 1, + Role = "assistant", Type = "text", + Content = content, Tag = "panel" + }); } await SseHelper.TryWriteSse(response, "panel_member_done", diff --git a/TODO.md b/TODO.md index 6d56219..29288f7 100644 --- a/TODO.md +++ b/TODO.md @@ -53,3 +53,7 @@ - [x] 🖥️ Windows Desktop App - WPF + WebView2 Wrapper mit konfigurierbarer URL - [ ] 🎨 Canvas - HTML/CSS/JS inline rendern in sandboxed iframes mit Resize und Source-View (Message-Tag: `[canvas::<height (optional)>]...[/canvas]`) + +## Multi-Modell-Panel (Fusion/MoA) +- [x] 🔄 MoA-Zwischenrunden persistieren (F5 überleben) - `RunPanelMember` (`Services/AnthropicService.Panel.cs`) persistiert jetzt JEDE Proposer-Runde (statt nur der finalen) mit `layer`/`totalLayers` im JSON-Transkript; `wwwroot/js/history.js` gruppiert die Panel-Bubbles nach Layer und rendert die "Runde k/N"-Divider beim Reload. +- [ ] 💰 Member-Token in die Chain-Kosten - Panel-Member-Tokens fließen nicht ins `TokenUsage` → Badge zeigt nur Worker-Kosten (bei MoA jetzt ≈ Runden × Agenten, also spürbar). Ziel: Usage aus allen drei Member-Pfaden (Anthropic-Loop, CLI-Events, OpenAI-Response) thread-safe (lock/Interlocked) ins geteilte `usage`-Objekt in `Services/AnthropicService.Panel.cs` akkumulieren. diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index da623dd..2a9ef23 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -231,14 +231,40 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { continue; } - // ── Panel (Fusion): je Member eine eigene Bubble ── + // ── Panel (Fusion/MoA): je Member eine eigene Bubble; bei MoA nach Runden gruppiert mit Divider ── if (msg.tag === 'panel') { const panelGroup = [msg]; while (_msgIdx + 1 < msgs.length && msgs[_msgIdx + 1].tag === 'panel') { _msgIdx++; panelGroup.push(msgs[_msgIdx]); } + // Layer-Info aus dem JSON-Transkript lesen (Alt-Format / kein layer → Runde 1). + var _panelMaxLayers = 1; + var _panelLayers = []; + for (var _pli = 0; _pli < panelGroup.length; _pli++) { + var _pLayer = 1; + var _pContent = panelGroup[_pli].content || ''; + if (_pContent.charAt(0) === '{') { + try { + var _pData = JSON.parse(_pContent); + if (_pData.layer) _pLayer = _pData.layer; + if (_pData.totalLayers > _panelMaxLayers) _panelMaxLayers = _pData.totalLayers; + } catch (e) {} + } + _panelLayers.push(_pLayer); + } + // In id-Reihenfolge rendern; bei MoA (>1 Runde) einen Divider beim Runden-Wechsel einfügen + // (spiegelt startPanelLayer aus stream-ui.js). Da die Runden SEQUENZIELL persistiert werden, + // ist panelGroup bereits nach Runde geordnet (Layer-1-ids < Layer-2-ids). + var _pLastLayer = null; for (var _pi = 0; _pi < panelGroup.length; _pi++) { + if (_panelMaxLayers > 1 && _panelLayers[_pi] !== _pLastLayer) { + var _pDiv = document.createElement('div'); + _pDiv.className = 'panel-layer-divider'; + _pDiv.innerHTML = '<span><i class="fa fa-clone"></i> Runde ' + _panelLayers[_pi] + ' / ' + _panelMaxLayers + '</span>'; + fragment.appendChild(_pDiv); + _pLastLayer = _panelLayers[_pi]; + } fragment.appendChild(_renderPanelMemberBubble(panelGroup[_pi], archived)); } continue;