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
@@ -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;
|
||||
|
||||
@@ -156,6 +156,19 @@
|
||||
<div class="agent-select-empty"><i class="fa fa-flag-checkered"></i> Kein Finalizer-Agent</div>
|
||||
</div>
|
||||
<input type="hidden" id="finalizerAgentSelect" value="">
|
||||
<label>Multi-Modell-Panel <small>(Fusion)</small></label>
|
||||
<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>
|
||||
<div class="custom-commands" id="customCommandsList"></div>
|
||||
<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');
|
||||
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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
function openTelegramBotPickerModal(bots, currentBotId, onSelect) {
|
||||
|
||||
@@ -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 = '<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)
|
||||
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');
|
||||
|
||||
@@ -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 = '<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 ──
|
||||
|
||||
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 += ' <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;
|
||||
return header;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user