From a4c73e3bfd9e0b262f3b25be47bf8a2d853f522a Mon Sep 17 00:00:00 2001 From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:11:18 +0200 Subject: [PATCH] fix(group): route raw child chats as whispers --- routes/chat_routes.py | 145 +++++++++++++++++++++++- routes/session_routes.py | 3 + static/app.js | 79 ++++++++++++- static/index.html | 4 + static/js/chat.js | 21 ++-- static/js/chatRenderer.js | 13 +++ static/js/group.js | 124 +++++++++++++++++++-- static/js/modelPicker.js | 5 +- static/js/sessions.js | 152 ++++++++++++++++++++++++-- static/style.css | 18 +++ tests/test_group_chat_state_routes.py | 44 +++++++- tests/test_group_chat_storage.py | 91 +++++++++++++++ 12 files changed, 665 insertions(+), 34 deletions(-) diff --git a/routes/chat_routes.py b/routes/chat_routes.py index b081d5f1c..90dd83fdc 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -28,6 +28,7 @@ from src.auth_helpers import effective_user, get_current_user from routes.session_routes import _verify_session_owner from routes.document_helpers import _owner_session_filter from core.database import SessionLocal, get_session_mode, set_session_mode +from core.database import GroupChatState from core.database import Session as DBSession, ChatMessage as DBChatMessage from core.database import Document as DBDocument, ModelEndpoint from core.log_safety import redact_url @@ -56,6 +57,115 @@ logger = logging.getLogger(__name__) _active_streams: Dict[str, dict] = {} +def _group_value(value: Any, max_len: int = 1024) -> str: + if value is None: + return "" + if not isinstance(value, str): + value = str(value) + return value.strip()[:max_len] + + +def _group_participant_label(model: dict | None, idx: int) -> str: + if not isinstance(model, dict): + return f"Participant {idx + 1}" + character = model.get("character") + if not isinstance(character, dict): + character = {} + for value in ( + model.get("_groupName"), + character.get("characterName"), + model.get("display"), + model.get("mid"), + ): + text = _group_value(value) + if text: + return text + return f"Participant {idx + 1}" + + +def _group_child_whisper_context(session_id: str, owner: str | None) -> dict | None: + """Return parent metadata when a direct chat targets a group child session.""" + if not session_id: + return None + target_id = str(session_id) + db = SessionLocal() + try: + q = db.query(GroupChatState) + if owner is not None: + q = q.filter(GroupChatState.owner == owner) + for row in q.all(): + state = row.state if isinstance(row.state, dict) else {} + participant_ids = state.get("participantSessions") + if not isinstance(participant_ids, list): + continue + models = state.get("models") + if not isinstance(models, list): + models = [] + for idx, participant_id in enumerate(participant_ids): + if not participant_id or str(participant_id) != target_id: + continue + model = models[idx] if idx < len(models) and isinstance(models[idx], dict) else {} + return { + "parent_session_id": row.parent_session_id, + "participant_session_id": target_id, + "participant_index": idx, + "participant_name": _group_participant_label(model, idx), + "participant_model": _group_value(model.get("mid") or model.get("display")), + } + finally: + db.close() + return None + + +def _group_parent_add_message(session_manager, ctx: dict | None, role: str, content: Any, metadata: dict | None) -> None: + if not ctx or not ctx.get("parent_session_id"): + return + try: + parent = session_manager.get_session(ctx["parent_session_id"]) + except KeyError: + return + parent.add_message(ChatMessage(role, content, metadata=metadata)) + session_manager.save_sessions() + + +def _mirror_group_child_user_message(session_manager, ctx: dict | None, content: Any) -> None: + if not ctx: + return + _group_parent_add_message( + session_manager, + ctx, + "user", + content, + { + "group_whisper": True, + "whisper_to": ctx["participant_name"], + "whisper_to_session": ctx["participant_session_id"], + "whisper_to_model": ctx.get("participant_model", ""), + }, + ) + + +def _mirror_group_child_assistant_message( + session_manager, + ctx: dict | None, + full_response: str, + model: str, + metrics: dict | None, +) -> None: + if not ctx or not full_response: + return + metadata = dict(metrics) if metrics else {} + metadata.update({ + "group_model": ctx["participant_name"], + "model": model, + "group_whisper": True, + "whisper_from": ctx["participant_name"], + "whisper_from_session": ctx["participant_session_id"], + }) + content, metadata = clean_thinking_for_save(full_response, metadata) + _group_parent_add_message(session_manager, ctx, "assistant", content, metadata) + + def _stream_set(session_id: str, **fields) -> None: """Update fields on the active-stream entry for `session_id`, or no-op if the entry has already been popped. Using .get() avoids a @@ -731,7 +841,7 @@ def setup_chat_routes( use_rag = form_data.get("use_rag") search_context = form_data.get("search_context") # pre-fetched web search results (compare mode) compare_mode = str(form_data.get("compare_mode", "")).lower() == "true" - incognito = str(form_data.get("incognito", "")).lower() == "true" + group_internal = str(form_data.get("group_internal", "")).lower() == "true" plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true" chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent' # Workspace: confine the agent's file/shell tools to this folder. @@ -1149,6 +1259,17 @@ def setup_chat_routes( # Enforce per-user privileges _privs = {} _user = ctx.user + group_child_whisper = None if group_internal else _group_child_whisper_context(session, _user) + if group_child_whisper and not incognito and not compare_mode: + try: + mirrored_user_content = message + if sess.history: + last_msg = sess.history[-1] + if getattr(last_msg, "role", None) == "user": + mirrored_user_content = getattr(last_msg, "content", message) + _mirror_group_child_user_message(session_manager, group_child_whisper, mirrored_user_content) + except Exception: + logger.exception("Failed to mirror group child user message for session %s", session) if _user and hasattr(request.app.state, 'auth_manager') and request.app.state.auth_manager: _privs = request.app.state.auth_manager.get_privileges(_user) if _privs: @@ -1633,6 +1754,17 @@ def setup_chat_routes( ) if _saved_id: yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n' + if group_child_whisper and not incognito and not compare_mode: + try: + _mirror_group_child_assistant_message( + session_manager, + group_child_whisper, + full_response, + last_metrics.get("model") if last_metrics else sess.model, + last_metrics, + ) + except Exception: + logger.exception("Failed to mirror group child assistant message for session %s", session) run_post_response_tasks( sess, session_manager, session, message, full_response, _metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager, @@ -1794,6 +1926,17 @@ def setup_chat_routes( ) if _saved_id: yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n' + if group_child_whisper and not incognito and not compare_mode: + try: + _mirror_group_child_assistant_message( + session_manager, + group_child_whisper, + full_response, + last_metrics.get("model") if last_metrics else sess.model, + last_metrics, + ) + except Exception: + logger.exception("Failed to mirror group child assistant message for session %s", session) run_post_response_tasks( sess, session_manager, session, message, _response_to_save, _metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager, diff --git a/routes/session_routes.py b/routes/session_routes.py index ec6f3c4ac..b75b8a5ff 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -338,6 +338,9 @@ def _group_participants_for_user(db, user) -> dict[str, list[dict]]: "index": idx, "name": _group_participant_label(model, idx), "model": _group_state_str(model.get("display") or model.get("mid"), 1024), + "model_id": _group_state_str(model.get("mid"), 1024), + "endpoint_url": _group_state_str(model.get("url"), 2048), + "endpoint_id": _group_state_str(model.get("endpointId"), 1024), }) if participants: participants_by_parent[str(parent_id)] = participants diff --git a/static/app.js b/static/app.js index 97f0ae77e..7cec61ba1 100644 --- a/static/app.js +++ b/static/app.js @@ -900,7 +900,8 @@ function initializeEventListeners() { if (chk) chk.checked = active; // Hide/show model picker const _mpw = el('model-picker-wrap'); - if (_mpw) _mpw.style.display = active ? 'none' : ''; + const whisperActive = !!(sessionModule && sessionModule.isCurrentGroupChild && sessionModule.isCurrentGroupChild()); + if (_mpw) _mpw.style.display = (active || whisperActive) ? 'none' : ''; // Mutual exclusion: group disables research + web search if (active) { _syncResearchIndicator(false); @@ -966,6 +967,33 @@ function initializeEventListeners() { if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; } } + /** Sync raw participant whisper indicator. */ + function _syncWhisperIndicator(active, target = null) { + const btn = el('whisper-toggle-btn'); + const label = el('whisper-toggle-label'); + const name = target && target.name ? String(target.name) : ''; + if (btn) { + btn.style.display = active ? '' : 'none'; + btn.classList.toggle('active', active); + btn.title = active + ? `Whisper to ${name || 'group participant'} - click to open group` + : 'Whisper mode active'; + if (target && target.parent_session_id) { + btn.dataset.parentSessionId = String(target.parent_session_id); + } else { + delete btn.dataset.parentSessionId; + } + } + if (label) { + label.textContent = 'Whisper'; + } + const _mpw = el('model-picker-wrap'); + if (_mpw) { + const groupActive = !!(groupModule && groupModule.isActive && groupModule.isActive()); + _mpw.style.display = (active || groupActive) ? 'none' : ''; + } + } + // ── Close compare if active (used by all tool/sidebar activations) ── // Returns true if compare was active (page will reload), caller should return early function _closeCompareIfActive() { @@ -2029,6 +2057,7 @@ function initializeEventListeners() { // run locally — finds it instead of silently no-op'ing (the "group indicator // sometimes doesn't appear" bug). window._syncGroupIndicator = _syncGroupIndicator; + window._syncWhisperIndicator = _syncWhisperIndicator; // Init RAG state on load { const st = loadToggleState(); @@ -2560,6 +2589,19 @@ function initializeEventListeners() { } // ── Incognito mode toggle (on welcome screen) ── + const whisperToggleBtn = el('whisper-toggle-btn'); + if (whisperToggleBtn) { + whisperToggleBtn.addEventListener('click', () => { + const child = sessionModule && sessionModule.getCurrentGroupChildInfo + ? sessionModule.getCurrentGroupChildInfo() + : null; + const parentId = (child && child.parent_session_id) || whisperToggleBtn.dataset.parentSessionId; + if (parentId && sessionModule && sessionModule.selectSession) { + sessionModule.selectSession(parentId, { keepSidebar: true }); + } + }); + } + const incognitoBtn = el('incognito-btn'); const INCOGNITO_EYE_OPEN = ''; const INCOGNITO_EYE_CLOSED = ''; @@ -3891,7 +3933,8 @@ function startOdysseusApp() { if (!msg) { console.log('[group] Empty message, skipping'); return; } console.log('[group] Sending:', msg); chatRenderer.hideWelcomeScreen(); - chatRenderer.addMessage('user', msg); + const userMetadata = groupModule.getWhisperUserMetadata ? groupModule.getWhisperUserMetadata() : null; + chatRenderer.addMessage('user', msg, null, userMetadata); msgInput.value = ''; groupModule.sendMessage(msg); return; @@ -4021,6 +4064,16 @@ function startOdysseusApp() { return true; } + function _currentWhisperChildInfo() { + try { + return sessionModule && sessionModule.getCurrentGroupChildInfo + ? sessionModule.getCurrentGroupChildInfo() + : null; + } catch (_) { + return null; + } + } + function _updateSendBtnIcon() { if (!sendBtn) return; if (sendBtn.dataset.mode === 'streaming') { @@ -4043,9 +4096,13 @@ function startOdysseusApp() { } else if (!hasText && !hasFiles && !_isSttEnabled()) { clearTimeout(sendBtn._collapseTimer); // Group chat: always show send button, never newchat mode - if (groupModule && groupModule.isActive()) { + const whisperChild = _currentWhisperChildInfo(); + if ((groupModule && groupModule.isActive()) || whisperChild) { + const whisperTarget = groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null; sendBtn.innerHTML = _sendIcon; - sendBtn.title = 'Send to group'; + sendBtn.title = whisperChild + ? `Send whisper to ${whisperChild.name || 'participant'}` + : (whisperTarget ? `Whisper to ${whisperTarget.name}` : 'Send to group'); newMode = 'idle'; sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded'); } else { @@ -4083,15 +4140,25 @@ function startOdysseusApp() { const delay = wasExpanded ? 300 : 0; setTimeout(() => { if (sendBtn.dataset.mode !== 'send') return; + const groupActive = groupModule && groupModule.isActive && groupModule.isActive(); + const whisperTarget = groupActive && groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null; + const whisperChild = _currentWhisperChildInfo(); sendBtn.innerHTML = _sendIcon; - sendBtn.title = 'Send message'; + sendBtn.title = whisperChild + ? `Send whisper to ${whisperChild.name || 'participant'}` + : (whisperTarget ? `Whisper to ${whisperTarget.name}` : (groupActive ? 'Send to group' : 'Send message')); sendBtn.classList.remove('mic-mode', 'newchat-mode', 'anim-spin-swap'); sendBtn.classList.add('anim-spin'); sendBtn.addEventListener('animationend', () => sendBtn.classList.remove('anim-spin'), { once: true }); }, delay); } else { + const groupActive = groupModule && groupModule.isActive && groupModule.isActive(); + const whisperTarget = groupActive && groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null; + const whisperChild = _currentWhisperChildInfo(); sendBtn.innerHTML = _sendIcon; - sendBtn.title = 'Send message'; + sendBtn.title = whisperChild + ? `Send whisper to ${whisperChild.name || 'participant'}` + : (whisperTarget ? `Whisper to ${whisperTarget.name}` : (groupActive ? 'Send to group' : 'Send message')); sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land'); } } diff --git a/static/index.html b/static/index.html index 8257660fe..ea18478b0 100644 --- a/static/index.html +++ b/static/index.html @@ -1170,6 +1170,10 @@ Group +