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>
287 lines
9.5 KiB
JavaScript
287 lines
9.5 KiB
JavaScript
// ── DOM Helpers ──
|
|
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const chatEl = () => $('#chat');
|
|
const metaEl = () => $('#meta');
|
|
|
|
function esc(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML.replace(/"/g, '"');
|
|
}
|
|
|
|
function truncate(s, max) {
|
|
return s && s.length > max ? s.substring(0, max) + '...' : (s || '');
|
|
}
|
|
|
|
// Auto-Scroll: wird deaktiviert wenn User nach oben scrollt, reaktiviert wenn er ganz unten ist
|
|
var _autoScroll = true;
|
|
|
|
function initAutoScroll() {
|
|
const chat = chatEl();
|
|
if (!chat) return;
|
|
_autoScroll = true;
|
|
chat.addEventListener('scroll', function() {
|
|
const threshold = 60;
|
|
const atBottom = chat.scrollHeight - chat.scrollTop - chat.clientHeight <= threshold;
|
|
_autoScroll = atBottom;
|
|
});
|
|
}
|
|
|
|
function scrollDown() {
|
|
const chat = chatEl();
|
|
if (!chat) return;
|
|
if (!_autoScroll) return;
|
|
chat.scrollTop = chat.scrollHeight;
|
|
// Nach Font-Swap kann sich die Höhe ändern → nochmal scrollen
|
|
document.fonts.ready.then(() => {
|
|
if (_autoScroll) chat.scrollTop = chat.scrollHeight;
|
|
});
|
|
}
|
|
|
|
function scrollDownForce() {
|
|
const chat = chatEl();
|
|
if (!chat) return;
|
|
_autoScroll = true;
|
|
chat.scrollTop = chat.scrollHeight;
|
|
document.fonts.ready.then(() => {
|
|
chat.scrollTop = chat.scrollHeight;
|
|
});
|
|
}
|
|
|
|
function prettyModelName(model) {
|
|
let m = (model || `?`).split(`:`);
|
|
m = m[m.length - 1].split(`/`);
|
|
return m[m.length - 1];
|
|
}
|
|
|
|
// ── Toast Notifications ──
|
|
|
|
function showToast(message, type) {
|
|
type = type || 'info';
|
|
var container = document.getElementById('toastContainer');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = 'toastContainer';
|
|
document.body.appendChild(container);
|
|
}
|
|
var toast = document.createElement('div');
|
|
toast.className = 'toast toast-' + type;
|
|
var icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-times-circle' : 'fa-info-circle';
|
|
toast.innerHTML = '<i class="fa ' + icon + '"></i> ' + (message || '');
|
|
container.appendChild(toast);
|
|
// Einblenden
|
|
requestAnimationFrame(function() { toast.classList.add('toast-visible'); });
|
|
// Automatisch ausblenden
|
|
var timeout = setTimeout(function() { removeToast(toast); }, 4000);
|
|
toast.addEventListener('click', function() {
|
|
clearTimeout(timeout);
|
|
removeToast(toast);
|
|
});
|
|
}
|
|
|
|
function removeToast(toast) {
|
|
toast.classList.remove('toast-visible');
|
|
toast.addEventListener('transitionend', function() { toast.remove(); });
|
|
}
|
|
|
|
// ── EasyMDE Helper ──
|
|
|
|
function createMDE(element, options) {
|
|
options = options || {};
|
|
const mde = new EasyMDE({
|
|
element: element,
|
|
initialValue: options.initialValue || '',
|
|
toolbar: options.toolbar || ['bold', 'italic', 'heading', '|', 'unordered-list', 'ordered-list', '|', 'link', 'code', '|', 'preview'],
|
|
status: false,
|
|
spellChecker: false,
|
|
autoDownloadFontAwesome: false,
|
|
autofocus: options.autofocus || false,
|
|
minHeight: options.minHeight || '120px',
|
|
maxHeight: options.maxHeight || undefined,
|
|
placeholder: options.placeholder || '',
|
|
previewClass: ['editor-preview', 'markdown']
|
|
});
|
|
mde.codemirror.setOption('theme', 'default');
|
|
mde.codemirror.getWrapperElement().closest('.EasyMDEContainer').classList.add('fe-mde-wrap');
|
|
return mde;
|
|
}
|
|
|
|
// ── Card Preview (for Picker-Select-Cards) ──
|
|
|
|
/**
|
|
* Renders a card preview into a select-card element.
|
|
* Used by agent picker (session-page) and session picker (cronjob-page).
|
|
* @param {HTMLElement} cardEl - The container element
|
|
* @param {HTMLElement|null} innerCard - Result of buildAgentCard/buildSessionCard, or null for empty state
|
|
* @param {string} emptyIcon - FA icon class for empty state
|
|
* @param {string} emptyText - Text for empty state
|
|
* @param {string} changeClass - CSS class for the change indicator (e.g. 'agent-select-change', 'session-select-change')
|
|
*/
|
|
function _renderCardPreview(cardEl, innerCard, emptyIcon, emptyText, changeClass) {
|
|
cardEl.innerHTML = '';
|
|
if (!innerCard) {
|
|
cardEl.innerHTML = '<div class="' + changeClass.replace('-change', '-empty') + '"><i class="fa ' + emptyIcon + '"></i> ' + emptyText + '</div>';
|
|
return;
|
|
}
|
|
while (innerCard.firstChild) cardEl.appendChild(innerCard.firstChild);
|
|
cardEl.insertAdjacentHTML('beforeend', '<span class="' + changeClass + '"><i class="fa fa-pencil"></i></span>');
|
|
}
|
|
|
|
// ── More/Expand Button ──
|
|
|
|
function initMoreButton(moreEl) {
|
|
if (!moreEl) return;
|
|
moreEl.addEventListener('click', () => {
|
|
const content = moreEl.querySelector('[hidden]');
|
|
const parent = moreEl.parentNode;
|
|
while (content.firstChild) parent.insertBefore(content.firstChild, moreEl);
|
|
moreEl.remove();
|
|
// EasyMDE/CodeMirror braucht refresh() nach Sichtbarwerden
|
|
parent.querySelectorAll('.EasyMDEContainer').forEach(c => {
|
|
const cm = c.querySelector('.CodeMirror');
|
|
if (cm && cm.CodeMirror) cm.CodeMirror.refresh();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Button Loading Helper ──
|
|
|
|
/**
|
|
* Disables a button, sets loading text, runs async function, then restores.
|
|
* @param {HTMLElement} btn - The button element
|
|
* @param {string} loadingText - Text while loading (e.g. 'Speichern...')
|
|
* @param {Function} asyncFn - Async function to run
|
|
* @returns {Promise<*>} Result of asyncFn
|
|
*/
|
|
async function withButtonLoading(btn, loadingText, asyncFn) {
|
|
const originalText = btn.textContent;
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.textContent = loadingText;
|
|
try {
|
|
return await asyncFn();
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
}
|
|
}
|
|
|
|
// ── Date Formatting ──
|
|
|
|
function formatDate(iso) {
|
|
if (!iso) return '';
|
|
return new Date(iso).toLocaleString('de-AT', {
|
|
day: '2-digit', month: '2-digit', year: '2-digit',
|
|
hour: '2-digit', minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function formatTimestamp(isoString) {
|
|
if (!isoString) return '';
|
|
var d = new Date(isoString);
|
|
if (isNaN(d.getTime())) return '';
|
|
var dd = String(d.getDate()).padStart(2, '0');
|
|
var mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
var yyyy = d.getFullYear();
|
|
var hh = String(d.getHours()).padStart(2, '0');
|
|
var min = String(d.getMinutes()).padStart(2, '0');
|
|
var ss = String(d.getSeconds()).padStart(2, '0');
|
|
return dd + '.' + mm + '.' + yyyy + ' ' + hh + ':' + min + ':' + ss;
|
|
}
|
|
|
|
// ── Meta Bar ──
|
|
|
|
function setMeta(text) {
|
|
metaEl().textContent = text;
|
|
}
|
|
|
|
function setMetaHtml(html) {
|
|
metaEl().innerHTML = html;
|
|
}
|
|
|
|
// ── Older Messages Button ──
|
|
|
|
function showOlderMessagesButton(remainingCount) {
|
|
removeOlderMessagesButton();
|
|
const btn = document.createElement('button');
|
|
btn.className = 'archive-load-btn older-messages-btn';
|
|
btn.innerHTML = '<i class="fa fa-arrow-up"></i> ' + remainingCount + ' ältere Nachrichten laden';
|
|
btn.addEventListener('click', () => loadOlderMessages());
|
|
chatEl().prepend(btn);
|
|
}
|
|
|
|
function removeOlderMessagesButton() {
|
|
const btn = chatEl().querySelector('.older-messages-btn');
|
|
if (btn) btn.remove();
|
|
}
|
|
|
|
// ── Archive Button ──
|
|
|
|
function showArchiveLoadButton() {
|
|
const existing = chatEl().querySelector('.archive-load-btn:not(.older-messages-btn)');
|
|
if (existing) existing.remove();
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'archive-load-btn';
|
|
btn.innerHTML = '<i class="fa fa-archive"></i> Archivierte Nachrichten laden';
|
|
btn.addEventListener('click', loadLatestArchive);
|
|
|
|
// Immer ganz oben im Chat (aber nach dem older-messages-btn falls vorhanden)
|
|
const olderBtn = chatEl().querySelector('.older-messages-btn');
|
|
if (olderBtn) {
|
|
olderBtn.after(btn);
|
|
} else {
|
|
chatEl().prepend(btn);
|
|
}
|
|
}
|
|
|
|
function removeArchiveLoadButton() {
|
|
const btns = chatEl().querySelectorAll('.archive-load-btn:not(.older-messages-btn)');
|
|
btns.forEach(b => b.remove());
|
|
}
|
|
|
|
// ── Review/Sub-Agent Helpers ──
|
|
|
|
function _isReviewMessage(content) {
|
|
return content && (content.startsWith('[Review:') || content.startsWith('[Review-Feedback') || content.startsWith('[Finalizer:') || content.startsWith('[Preparer:') || content.startsWith('[Panel:'));
|
|
}
|
|
|
|
/**
|
|
* Returns { icon, defaultName } for a sub-agent type.
|
|
* @param {string} agentType - 'preparer', 'finalizer', or 'review'
|
|
*/
|
|
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' };
|
|
}
|
|
|
|
/**
|
|
* Creates a review/sub-agent header element.
|
|
* @param {string} agentType - 'preparer', 'finalizer', or 'review'
|
|
* @param {string} agentName - Display name of the agent
|
|
* @param {object} [extra] - Optional: { round, maxRounds } for review
|
|
*/
|
|
function _createSubAgentHeader(agentType, agentName, extra) {
|
|
var meta = _getSubAgentMeta(agentType);
|
|
var header = document.createElement('div');
|
|
header.className = 'review-header';
|
|
var html = '<i class="fa ' + meta.icon + '"></i> <strong>' + esc(agentName || meta.defaultName) + '</strong>';
|
|
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;
|
|
}
|
|
|
|
// ── Streamed Elements ──
|
|
|
|
function removeStreamedElements() {
|
|
chatEl().querySelectorAll('.streamed').forEach(el => el.remove());
|
|
}
|