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
+