Persist MoA intermediate layers so they survive reload
Previously only the final proposer layer was persisted, so after a reload the intermediate MoA rounds and their "Runde k/N" dividers were gone — the history showed only the last refined round, not the full live view. Persist every proposer round instead, tagging each member's JSON transcript with its layer index, and rebuild the round structure on reload. - AnthropicService.Panel.cs: persist every round unconditionally; drop the now-dead persist parameter and isFinalLayer local; write layer/totalLayers into the transcript; iteration = layer*100+index+1 so DB rows are layer-aware (no UNIQUE constraint on chain_id/iteration, id-based ordering) - history.js: group panel bubbles by layer on reload and insert the "Runde k / N" divider on round change for MoA (totalLayers > 1), matching the live startPanelLayer view; back-compat for old format (default layer 1) - TODO.md: check off the MoA persistence item Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
1641bf11b6
commit
0f87ccdcab
3 files changed
+54
-28
No files matched your search
@@ -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 (<previous_layer_responses>) 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
|
||||
|
||||
/// <summary>
|
||||
/// Führt das Panel aus (Fusion = 1 Runde, MoA = <paramref name="layers"/> Runden): pro Runde
|
||||
/// alle Member parallel; ab Runde 2 sieht jeder Member die Antworten der Vorrunde. Die FINALE
|
||||
/// Runde wird persistiert (Tag="panel"); ihr Synthese-Block wird in die letzte User-Message von
|
||||
/// 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
|
||||
/// <paramref name="workerMessages"/> gemergt. Danach streamt der Worker als Synthesizer ganz
|
||||
/// normal weiter. Gibt die Anzahl beigetragener Member der finalen Runde zurück
|
||||
/// (0 = nichts injiziert → Worker antwortet regulär).
|
||||
@@ -72,7 +72,6 @@ public partial class AnthropicService
|
||||
List<PanelMemberResult> 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<object>();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
segments.Add(new Dictionary<string, object> { ["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<object>();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
segments.Add(new Dictionary<string, object> { ["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",
|
||||
|
||||
@@ -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:<title>:<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.
|
||||
+27
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user