diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 27925e7..bf919da 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -439,23 +439,82 @@ /* Unified Modus-Toggle: Aus / Plan / Fusion / MoA (ein einziger #planModeBtn) */ #planModeBtn.plan-mode-active { color: var(--primary); - background: color-mix(in srgb, var(--primary) 15%, transparent); - border-radius: var(--border-radius); + background: color-mix(in srgb, var(--primary) 25%, transparent); } #planModeBtn.panel-mode-fusion { color: var(--primary); - background: color-mix(in srgb, var(--primary) 15%, transparent); - border-radius: var(--border-radius); + background: color-mix(in srgb, var(--primary) 25%, transparent); } #planModeBtn.panel-mode-moa { color: var(--primary); - background: color-mix(in srgb, var(--primary) 28%, transparent); - border-radius: var(--border-radius); + background: color-mix(in srgb, var(--primary) 25%, transparent); } #planModeBtn.ultracode-active { - color: var(--info); - background: color-mix(in srgb, var(--info) 18%, transparent); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 25%, transparent); +} + +/* Modus-Menü (Dropdown: Aus / Plan / Ultracode / Fusion / MoA) */ + +.composite-menu { + position: fixed; + z-index: 10000; + background: var(--f2-bg); + border: var(--border-width) solid var(--f2-border); border-radius: var(--border-radius); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 4px 16px rgb(0 0 0 / 0.25); + min-width: 16em; + padding-block: calc(var(--badge-spacing) / 2); +} + +.composite-menu-item { + display: flex; + align-items: center; + gap: var(--badge-spacing); + padding: var(--menu-item-padding); + background: none; + border: none; + border-radius: unset; + text-align: left; + white-space: nowrap; + cursor: pointer; + color: var(--text); +} + +.composite-menu-item:hover { + background: var(--f3-bg); + border: none; +} + +.composite-menu-item.active { + color: var(--primary); +} + +.composite-menu-icon { + width: 1.3em; + text-align: center; + flex-shrink: 0; +} + +.composite-menu-text { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.composite-menu-desc { + font-size: var(--font-size); + opacity: var(--semi-transparent); +} + +.composite-menu-check { + margin-left: auto; + flex-shrink: 0; + color: var(--primary); } /* Emoji Picker */ diff --git a/wwwroot/index.html b/wwwroot/index.html index fcee1e1..20b0268 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -458,7 +458,7 @@ - + diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 68329bf..9566eb0 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -217,65 +217,70 @@ function handleStreamEvent(eventType, data) { } } -// ── Unified Modus-Toggle (Aus / Plan / Fusion / MoA) ── +// ── Unified Modus-Menü (Aus / Plan / Ultracode / Fusion / MoA) ── // -// Ein einziger Button (#planModeBtn) schaltet zyklisch durch alle Betriebsmodi: -// Aus → Plan-Modus → Fusion (Panel) → MoA (Panel) → Aus +// Der Button (#planModeBtn) öffnet ein Dropdown-Menü (Muster wie der Emoji-Picker), aus dem +// der Betriebsmodus direkt gewählt wird. Der Button zeigt weiterhin das Symbol des aktiven +// Modus; im Menü ist der aktive Eintrag markiert. // -// Plan-Modus ist transient (wird bei Session-Wechsel zurückgesetzt, nie in der Session -// persistiert). Fusion/MoA werden in der Session persistiert (apiUpdateSession) und -// server-seitig aus ctx.MultiModelMode gelesen – daher reicht ein PATCH. +// Plan-Modus und Ultracode sind transient (pro Request, nicht in der Session persistiert) und +// laufen ohne Panel → beim Wählen wird multiModelMode der Session auf 'off' gesetzt, damit kein +// zuvor gewähltes Fusion/MoA-Panel nebenherläuft (das Menü erlaubt direkte Sprünge, anders als +// der frühere Zyklus, der immer durch 'off' lief). Fusion/MoA werden in der Session persistiert +// und server-seitig aus ctx.MultiModelMode gelesen. // -// _planModeActive bleibt als Compat-Variable (sendMessage + modals.js lesen sie direkt). +// _planModeActive / _ultracodeActive bleiben als Compat-Variablen (sendMessage + modals.js lesen +// sie direkt). + +// Metadaten je Modus – eine Quelle für Button-Render UND Menü-Aufbau: Reihenfolge im Menü, +// Icon (Button + Menü), Label, Button-Aktiv-Klasse, Input-Placeholder, kurze Beschreibung. +const _COMPOSITE_MODES = [ + { mode: 'off', icon: 'fa-ban', label: 'Aus', btnClass: null, placeholder: 'Nachricht...', desc: 'Normaler Einzel-Agent' }, + { mode: 'plan', icon: 'fa-map', label: 'Plan-Modus', btnClass: 'plan-mode-active', placeholder: 'Planen...', desc: 'Nur analysieren & planen (read-only)' }, + { mode: 'ultracode', icon: 'fa-sitemap', label: 'Ultracode', btnClass: 'ultracode-active', placeholder: 'Ultracode – als Workflow...', desc: 'Dynamischer Workflow (mehrere Subagenten)' }, + { mode: 'fusion', icon: 'fa-users', label: 'Fusion', btnClass: 'panel-mode-fusion', placeholder: 'Nachricht...', desc: 'Panel: Antworten fusionieren' }, + { mode: 'moa', icon: 'fa-clone', label: 'MoA', btnClass: 'panel-mode-moa', placeholder: 'Nachricht...', desc: 'Panel: Mixture-of-Agents' } +]; let _compositeMode = 'off'; // 'off' | 'plan' | 'ultracode' | 'fusion' | 'moa' let _compositeHasAgents = false; -// Compat-Variablen – werden durch cycleCompositeMode / initCompositeMode synchron gehalten +// Compat-Variablen – werden durch setCompositeMode / initCompositeMode synchron gehalten let _planModeActive = false; // true genau wenn _compositeMode === 'plan' let _ultracodeActive = false; // true genau wenn _compositeMode === 'ultracode' (transient, pro Request) -const _COMPOSITE_ORDER = ['off', 'plan', 'ultracode', 'fusion', 'moa']; +// Offenes Modus-Dropdown + Outside-Click-Handler (wie beim Emoji-Picker). +let _compositeMenuEl = null; +let _compositeMenuCloseHandler = null; -// Serialisierte Modus-Persistenz. Schnelles Durchklicken (off→plan→fusion→moa) feuert sonst -// ÜBERLAPPENDE PATCHes, deren Commit-Reihenfolge nicht garantiert ist → der zuletzt committete -// Zwischenwert 'fusion' kann 'moa' überschreiben. Und ein direkt danach gesendeter Chat-Request -// lädt die Session, BEVOR 'moa' committed ist → das Backend liest den alten Modus (Fusion → 1 Runde -// statt MoA). Die Kette garantiert (a) Reihenfolge (letzter Klick gewinnt) und (b) dass sendMessage -// das offene PATCH abwarten kann. Die Kette rejected nie (Fehler werden hier geschluckt). +// Serialisierte Modus-Persistenz. Schnell hintereinander gewählte Modi feuern sonst ÜBERLAPPENDE +// PATCHes, deren Commit-Reihenfolge nicht garantiert ist → ein Zwischenwert kann den letzten +// überschreiben. Und ein direkt danach gesendeter Chat-Request lädt die Session, BEVOR der Modus +// committed ist → das Backend liest den alten Modus. Die Kette garantiert (a) Reihenfolge (letzte +// Wahl gewinnt) und (b) dass sendMessage das offene PATCH abwarten kann. Sie rejected nie (Fehler +// werden hier geschluckt). let _modePersist = Promise.resolve(); +function _compositeMeta(mode) { + for (var i = 0; i < _COMPOSITE_MODES.length; i++) { + if (_COMPOSITE_MODES[i].mode === mode) return _COMPOSITE_MODES[i]; + } + return _COMPOSITE_MODES[0]; +} + function _renderCompositeBtn() { var btn = $('#planModeBtn'); if (!btn) return; btn.classList.remove('plan-mode-active', 'ultracode-active', 'panel-mode-fusion', 'panel-mode-moa'); + var meta = _compositeMeta(_compositeMode); var icon = btn.querySelector('i'); + if (icon) icon.className = 'fa ' + meta.icon; + if (meta.btnClass) btn.classList.add(meta.btnClass); + btn.title = _compositeMode === 'off' + ? 'Modus wählen (aktuell: Aus)' + : 'Modus: ' + meta.label + ' · klicken zum Wechseln'; var input = $('#input'); - if (_compositeMode === 'plan') { - btn.classList.add('plan-mode-active'); - if (icon) icon.className = 'fa fa-map'; - btn.title = 'Plan-Modus aktiv · klicken → Ultracode'; - if (input) input.placeholder = 'Planen...'; - } else if (_compositeMode === 'ultracode') { - btn.classList.add('ultracode-active'); - if (icon) icon.className = 'fa fa-sitemap'; - btn.title = 'Ultracode aktiv (dynamischer Workflow) · klicken → Fusion'; - if (input) input.placeholder = 'Ultracode – als Workflow...'; - } else if (_compositeMode === 'fusion') { - btn.classList.add('panel-mode-fusion'); - if (icon) icon.className = 'fa fa-users'; - btn.title = 'Fusion aktiv · klicken → MoA'; - if (input) input.placeholder = 'Nachricht...'; - } else if (_compositeMode === 'moa') { - btn.classList.add('panel-mode-moa'); - if (icon) icon.className = 'fa fa-clone'; - btn.title = 'MoA aktiv · klicken → Aus'; - if (input) input.placeholder = 'Nachricht...'; - } else { - if (icon) icon.className = 'fa fa-map'; - btn.title = 'Plan-Modus / Panel umschalten'; - if (input) input.placeholder = 'Nachricht...'; - } + if (input) input.placeholder = meta.placeholder; } // Initialisiert den Button aus der geladenen Session (bei jedem Chat-Wechsel aufrufen). @@ -288,46 +293,106 @@ function initCompositeMode(panelMode, hasAgents) { _renderCompositeBtn(); } -// Schaltet Aus → Plan → Fusion → MoA → Aus durch. -// Fusion/MoA werden sofort in die Session persistiert; Plan-Modus ist transient (kein Server-Call). -async function cycleCompositeMode() { - var idx = _COMPOSITE_ORDER.indexOf(_compositeMode); - _compositeMode = _COMPOSITE_ORDER[(idx + 1) % _COMPOSITE_ORDER.length]; - _planModeActive = (_compositeMode === 'plan'); - _ultracodeActive = (_compositeMode === 'ultracode'); +// Setzt den Modus direkt (aus dem Menü gewählt). +// Persistiert das EFFEKTIVE Panel der Session (off/fusion/moa): für plan/ultracode also 'off', +// damit kein zuvor gewähltes Panel nebenherläuft. Plan/Ultracode selbst sind transient (Request-Flags). +async function setCompositeMode(mode) { + if (mode === _compositeMode) return; // schon aktiv → nichts tun + if (_compositeMeta(mode).mode !== mode) return; // unbekannter Modus → ignorieren + + _compositeMode = mode; + _planModeActive = (mode === 'plan'); + _ultracodeActive = (mode === 'ultracode'); _renderCompositeBtn(); - // Nur Fusion/MoA/Off brauchen einen Server-Call (Plan + Ultracode sind transient pro Request). - // Auf die serialisierte Kette legen, damit aufeinanderfolgende Klicks in Reihenfolge committen - // (letzter Klick gewinnt) und sendMessage das offene PATCH abwarten kann. - if (_compositeMode !== 'plan' && _compositeMode !== 'ultracode') { - var sid = getCurrentSessionId(); - if (sid) { - var targetMode = _compositeMode; - _modePersist = _modePersist - .then(function () { return apiUpdateSession(sid, { multiModelMode: targetMode }); }) - .catch(function (e) { - console.error('Modus speichern fehlgeschlagen:', e); - if (typeof showToast === 'function') showToast('Modus konnte nicht gespeichert werden', 'error'); - }); - } + // Effektiver Panel-Modus: nur Fusion/MoA aktivieren ein Panel, alles andere 'off'. + // Auf die serialisierte Kette legen, damit aufeinanderfolgende Wahlen in Reihenfolge committen + // (letzte gewinnt) und sendMessage das offene PATCH abwarten kann. + var panelMode = (mode === 'fusion' || mode === 'moa') ? mode : 'off'; + var sid = getCurrentSessionId(); + if (sid) { + _modePersist = _modePersist + .then(function () { return apiUpdateSession(sid, { multiModelMode: panelMode }); }) + .catch(function (e) { + console.error('Modus speichern fehlgeschlagen:', e); + if (typeof showToast === 'function') showToast('Modus konnte nicht gespeichert werden', 'error'); + }); } if (typeof showToast === 'function') { - if (_compositeMode === 'off') { + if (mode === 'off') { showToast('Modus: Aus', 'info'); - } else if (_compositeMode === 'plan') { + } else if (mode === 'plan') { showToast('Plan-Modus aktiv', 'info'); - } else if (_compositeMode === 'ultracode') { + } else if (mode === 'ultracode') { showToast('Ultracode aktiv – diese Anfrage läuft als dynamischer Workflow (mehr Tokens/Zeit)', 'info'); } else if (!_compositeHasAgents) { - showToast('Panel: ' + (_compositeMode === 'moa' ? 'MoA' : 'Fusion') + ' aktiv – aber keine Panel-Agenten konfiguriert (im Session-Bearbeiten festlegen)', 'info'); + showToast('Panel: ' + (mode === 'moa' ? 'MoA' : 'Fusion') + ' aktiv – aber keine Panel-Agenten konfiguriert (im Session-Bearbeiten festlegen)', 'info'); } else { - showToast('Panel: ' + (_compositeMode === 'moa' ? 'MoA' : 'Fusion') + ' aktiv', 'success'); + showToast('Panel: ' + (mode === 'moa' ? 'MoA' : 'Fusion') + ' aktiv', 'success'); } } } +// ── Modus-Dropdown-Menü (Muster wie der Emoji-Picker) ── + +function _buildCompositeMenu() { + var menu = document.createElement('div'); + menu.className = 'composite-menu'; + _COMPOSITE_MODES.forEach(function (m) { + var active = (m.mode === _compositeMode); + var item = document.createElement('button'); + item.className = 'composite-menu-item' + (active ? ' active' : ''); + item.innerHTML = + '' + + '' + + '' + m.label + '' + + '' + m.desc + '' + + '' + + (active ? '' : ''); + item.addEventListener('click', function (e) { + e.stopPropagation(); + closeCompositeMenu(); + setCompositeMode(m.mode); + }); + menu.appendChild(item); + }); + return menu; +} + +function toggleCompositeMenu(anchor) { + if (_compositeMenuEl) { closeCompositeMenu(); return; } + + var menu = _buildCompositeMenu(); + document.body.appendChild(menu); + _compositeMenuEl = menu; + + // Positionierung: über dem Button (Input-Leiste ist unten), linksbündig zum Button. + var rect = anchor.getBoundingClientRect(); + var h = menu.offsetHeight; + var w = menu.offsetWidth; + var top = rect.top - h - 6; + var left = rect.left; + if (top < 8) top = rect.bottom + 6; // kein Platz oben → unten + if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8; // rechts abgeschnitten + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + + _compositeMenuCloseHandler = function (e) { + if (!menu.contains(e.target) && e.target !== anchor && !anchor.contains(e.target)) closeCompositeMenu(); + }; + setTimeout(function () { document.addEventListener('click', _compositeMenuCloseHandler); }, 0); +} + +function closeCompositeMenu() { + if (_compositeMenuEl) { _compositeMenuEl.remove(); _compositeMenuEl = null; } + if (_compositeMenuCloseHandler) { + document.removeEventListener('click', _compositeMenuCloseHandler); + _compositeMenuCloseHandler = null; + } +} + // ── Chat State ── let isStreaming = false; diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index cff1597..aaf4f6c 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -572,12 +572,16 @@ registerPage('chat', { }); } - // Unified Modus-Toggle (Aus / Plan / Fusion / MoA) – aus der Session initialisieren + // Unified Modus-Menü (Aus / Plan / Ultracode / Fusion / MoA) – aus der Session initialisieren. + // Klick öffnet ein Dropdown (wie der Emoji-Picker), aus dem der Modus direkt gewählt wird. const compositeModeBtn = document.getElementById('planModeBtn'); if (compositeModeBtn) { const hasPanelAgents = !!(session.panelAgentIds && String(session.panelAgentIds).trim()); initCompositeMode(session.multiModelMode, hasPanelAgents); - compositeModeBtn.addEventListener('click', cycleCompositeMode); + compositeModeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleCompositeMenu(compositeModeBtn); + }); } // File Upload