mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
fix(group): route raw child chats as whispers
This commit is contained in:
parent
4a42524ca9
commit
a4c73e3bfd
12 changed files with 665 additions and 34 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const INCOGNITO_EYE_CLOSED = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1170,6 +1170,10 @@
|
|||
<span style="font-size:11px;margin-left:2px;">Group</span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Whisper mode active" id="whisper-toggle-btn" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/><path d="M12 8v5"/><path d="M9 10h6"/></svg>
|
||||
<span id="whisper-toggle-label" style="font-size:11px;margin-left:2px;">Whisper</span>
|
||||
</button>
|
||||
<input type="checkbox" id="group-toggle" style="display:none;">
|
||||
<!-- Character indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Persona active — click to deactivate" id="character-indicator-btn" style="display:none;">
|
||||
|
|
|
|||
|
|
@ -1234,8 +1234,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||
}
|
||||
})();
|
||||
|
||||
// Materialize pending session (deferred from model click) on first message
|
||||
if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
|
||||
// Materialize pending session (deferred from model click) on first message.
|
||||
// Raw group participant chats already have a fixed session/model; stale
|
||||
// pending chat state must not create a fresh standalone chat here.
|
||||
if (sessionModule.isCurrentGroupChild && sessionModule.isCurrentGroupChild()) {
|
||||
if (sessionModule.clearPendingChat) sessionModule.clearPendingChat();
|
||||
} else if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
|
||||
_sendPerf.mark('pending_session_begin');
|
||||
const ok = await sessionModule.materializePendingSession();
|
||||
_sendPerf.mark('pending_session_done');
|
||||
|
|
@ -1763,6 +1767,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||
holder._researchQuery = msg; // Store query for notification text
|
||||
|
||||
const modelName = _bestKnownStreamModel(selectedRouteForSend) || null;
|
||||
const groupChildInfo = sessionModule.getCurrentGroupChildInfo ? sessionModule.getCurrentGroupChildInfo() : null;
|
||||
const groupChildAlias = groupChildInfo && groupChildInfo.name ? groupChildInfo.name : '';
|
||||
|
||||
let loadingText = 'Initializing...';
|
||||
|
||||
|
|
@ -1778,13 +1784,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||
loadingText = 'Processing request...';
|
||||
}
|
||||
|
||||
var roleLabel = _modelRouteLabel(modelName, modelName);
|
||||
var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
||||
var roleLabel = groupChildAlias || _modelRouteLabel(modelName, modelName);
|
||||
var _charNameInit = groupChildAlias || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
|
||||
if (_charNameInit) roleLabel = _charNameInit;
|
||||
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
holder.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
holder._requestedModel = modelName;
|
||||
holder._actualModel = modelName;
|
||||
holder._characterName = _charNameInit || '';
|
||||
_applyModelColor(holder.querySelector('.role'), modelName);
|
||||
holder.style.position = 'relative';
|
||||
|
||||
|
|
@ -2778,7 +2785,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
|
||||
if (json.suffix) holder._roleSuffix = json.suffix;
|
||||
// Prepend character name if sent by server or set locally
|
||||
var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
|
||||
var _charName = json.character_name || holder._characterName || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
|
||||
if (_charName) holder._characterName = _charName;
|
||||
_setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
|
||||
suffix: holder._roleSuffix,
|
||||
|
|
@ -3470,12 +3477,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||
const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model;
|
||||
const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel;
|
||||
// Prepend character name if set
|
||||
var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
||||
var _charNameFinal = holder._characterName || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
|
||||
const roleEl = holder.querySelector('.role');
|
||||
if (roleEl) {
|
||||
_setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
|
||||
suffix: holder._roleSuffix,
|
||||
characterName: _charNameFinal || holder._characterName,
|
||||
characterName: _charNameFinal,
|
||||
});
|
||||
}
|
||||
holder.dataset.raw = accumulated;
|
||||
|
|
|
|||
|
|
@ -2333,6 +2333,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||
const pair = replyModelPair(modelName, metadata);
|
||||
const contModel = pair.actualModel || pair.requestedModel;
|
||||
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
|
||||
if (metadata?.character_name) roleEl.textContent = metadata.character_name;
|
||||
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
|
||||
roleEl.title = pair.requestedModel + ' -> ' + contModel;
|
||||
}
|
||||
|
|
@ -2482,6 +2483,9 @@ export function addMessage(role, content, modelName, metadata) {
|
|||
// --- Standard single-bubble message ---
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
|
||||
if (metadata?.group_whisper) {
|
||||
wrap.classList.add('msg-whisper');
|
||||
}
|
||||
|
||||
const r = document.createElement('div');
|
||||
r.className = 'role';
|
||||
|
|
@ -2498,6 +2502,15 @@ export function addMessage(role, content, modelName, metadata) {
|
|||
} else if (metadata?.character_name && role !== 'user' && !isSlash && !isCompacted) {
|
||||
_roleText = metadata.character_name;
|
||||
}
|
||||
if (metadata?.group_whisper) {
|
||||
if (role === 'user' && metadata.whisper_to) {
|
||||
_roleText = 'Whisper to ' + metadata.whisper_to;
|
||||
} else if (role !== 'user' && metadata.whisper_from) {
|
||||
_roleText = 'Whisper from ' + metadata.whisper_from;
|
||||
} else if (role !== 'user' && metadata.group_model) {
|
||||
_roleText = 'Whisper from ' + metadata.group_model;
|
||||
}
|
||||
}
|
||||
r.textContent = _roleText;
|
||||
if (role !== 'user') {
|
||||
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ let _abortControllers = [];
|
|||
let _mode = 'round-robin'; // 'parallel' or 'round-robin'
|
||||
let _roundRobinIdx = 0;
|
||||
let _parentSessionId = null;
|
||||
let _whisperTargetSessionId = null;
|
||||
const GROUP_STATE_KEY = 'odysseus-group-state';
|
||||
|
||||
export function init(apiBase) {
|
||||
|
|
@ -379,6 +380,74 @@ export function setActive(v) { _active = v; }
|
|||
export function getMode() { return _mode; }
|
||||
export function setMode(m) { _mode = m; }
|
||||
|
||||
function _participantDisplayName(modelIdx) {
|
||||
const model = _models[modelIdx] || {};
|
||||
return model._groupName ||
|
||||
(model.character ? model.character.characterName : '') ||
|
||||
model.display ||
|
||||
(model.mid ? chatRenderer.shortModel(model.mid) : '') ||
|
||||
`Participant ${modelIdx + 1}`;
|
||||
}
|
||||
|
||||
function _participantTargetForSession(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const targetId = String(sessionId);
|
||||
const idx = _participantSessions.findIndex(sid => sid && String(sid) === targetId);
|
||||
if (idx < 0 || !_models[idx]) return null;
|
||||
const model = _models[idx];
|
||||
return {
|
||||
index: idx,
|
||||
sessionId: String(_participantSessions[idx]),
|
||||
name: _participantDisplayName(idx),
|
||||
model: model.display || model.mid || '',
|
||||
};
|
||||
}
|
||||
|
||||
function _syncWhisperTargetUi() {
|
||||
const target = getWhisperTarget();
|
||||
document.querySelectorAll('.group-participant-row').forEach(row => {
|
||||
const active = !!target && row.dataset.groupParticipantId === target.sessionId;
|
||||
row.classList.toggle('whisper-active', active);
|
||||
row.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
}
|
||||
|
||||
function _whisperUserMetadata(target) {
|
||||
if (!target) return null;
|
||||
const model = _models[target.index] || {};
|
||||
return {
|
||||
group_whisper: true,
|
||||
whisper_to: target.name,
|
||||
whisper_to_session: target.sessionId,
|
||||
whisper_to_model: model.mid || '',
|
||||
};
|
||||
}
|
||||
|
||||
function _whisperPrompt(msg, target) {
|
||||
return `This is a private direct message from the user to you. Other group participants cannot see it.\n\n${msg}`;
|
||||
}
|
||||
|
||||
export function getWhisperTarget() {
|
||||
return _participantTargetForSession(_whisperTargetSessionId);
|
||||
}
|
||||
|
||||
export function getWhisperUserMetadata() {
|
||||
return _whisperUserMetadata(getWhisperTarget());
|
||||
}
|
||||
|
||||
export function setWhisperTarget(sessionId) {
|
||||
const target = _participantTargetForSession(sessionId);
|
||||
_whisperTargetSessionId = target ? target.sessionId : null;
|
||||
_syncWhisperTargetUi();
|
||||
return target;
|
||||
}
|
||||
|
||||
export function clearWhisperTarget() {
|
||||
_whisperTargetSessionId = null;
|
||||
_syncWhisperTargetUi();
|
||||
}
|
||||
|
||||
// ── Model Picker ─────────────────────────────────────
|
||||
|
||||
export async function showModelPicker() {
|
||||
|
|
@ -701,6 +770,8 @@ export function stopGroup() {
|
|||
_active = false;
|
||||
_models = [];
|
||||
_participantSessions = [];
|
||||
_whisperTargetSessionId = null;
|
||||
_syncWhisperTargetUi();
|
||||
localStorage.removeItem(GROUP_STATE_KEY);
|
||||
}
|
||||
|
||||
|
|
@ -712,15 +783,26 @@ export async function sendMessage(msg) {
|
|||
const box = document.getElementById('chat-history');
|
||||
if (!box) return;
|
||||
|
||||
const whisperTarget = getWhisperTarget();
|
||||
|
||||
// Save user message to parent session for persistence
|
||||
if (_parentSessionId) {
|
||||
fetch(`${API_BASE}/api/session/${_parentSessionId}/inject_messages`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{ role: 'user', content: msg }] }),
|
||||
body: JSON.stringify({ messages: [{
|
||||
role: 'user',
|
||||
content: msg,
|
||||
metadata: whisperTarget ? _whisperUserMetadata(whisperTarget) : null,
|
||||
}] }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (whisperTarget) {
|
||||
await _sendWhisper(msg, box, whisperTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mode === 'parallel') {
|
||||
await _sendParallel(msg, box);
|
||||
} else {
|
||||
|
|
@ -728,15 +810,16 @@ export async function sendMessage(msg) {
|
|||
}
|
||||
}
|
||||
|
||||
function _createGroupBubble(model, box) {
|
||||
function _createGroupBubble(model, box, options = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg msg-ai msg-group';
|
||||
wrap.className = 'msg msg-ai msg-group' + (options.whisper ? ' msg-whisper' : '');
|
||||
wrap.style.position = 'relative';
|
||||
|
||||
// Role label — use character name if assigned, otherwise model name
|
||||
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
|
||||
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
wrap.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
const displayLabel = options.whisper ? `Whisper from ${roleLabel}` : roleLabel;
|
||||
wrap.innerHTML = `<div class="role">${uiModule.esc(displayLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
|
||||
|
||||
// Spinner — identical to chat.js line 3062
|
||||
|
|
@ -750,6 +833,20 @@ function _createGroupBubble(model, box) {
|
|||
return wrap;
|
||||
}
|
||||
|
||||
async function _sendWhisper(msg, box, target) {
|
||||
const model = _models[target.index];
|
||||
const holder = _createGroupBubble(model, box, { whisper: true });
|
||||
uiModule.scrollHistory();
|
||||
|
||||
const ac = new AbortController();
|
||||
_abortControllers = [ac];
|
||||
await _streamToHolder(target.index, target.sessionId, msg, holder, ac, { whisper: true });
|
||||
_abortControllers = [];
|
||||
|
||||
_saveState();
|
||||
_saveStateToServer().catch(() => {});
|
||||
}
|
||||
|
||||
async function _sendParallel(msg, box) {
|
||||
const holders = _models.map(m => _createGroupBubble(m, box));
|
||||
uiModule.scrollHistory();
|
||||
|
|
@ -838,15 +935,17 @@ async function _syncAllResponses(holders) {
|
|||
}
|
||||
}
|
||||
|
||||
async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
|
||||
async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl, options = {}) {
|
||||
if (!sessionId) {
|
||||
holderEl.querySelector('.body').innerHTML = '<i style="opacity:0.5;">[Session creation failed]</i>';
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', msg);
|
||||
const target = options.whisper ? _participantTargetForSession(sessionId) : null;
|
||||
fd.append('message', target ? _whisperPrompt(msg, target) : msg);
|
||||
fd.append('session', sessionId);
|
||||
fd.append('group_internal', 'true');
|
||||
|
||||
let accumulated = '';
|
||||
let _buffer = '';
|
||||
|
|
@ -964,12 +1063,18 @@ async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
|
|||
// Save response to parent session for persistence
|
||||
if (accumulated && _parentSessionId) {
|
||||
const gName = _models[modelIdx]._groupName || _models[modelIdx].display;
|
||||
const metadata = { group_model: gName, model: _models[modelIdx].mid };
|
||||
if (options.whisper) {
|
||||
metadata.group_whisper = true;
|
||||
metadata.whisper_from = gName;
|
||||
metadata.whisper_from_session = String(sessionId);
|
||||
}
|
||||
fetch(`${API_BASE}/api/session/${_parentSessionId}/inject_messages`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{
|
||||
role: 'assistant', content: accumulated,
|
||||
metadata: { group_model: gName, model: _models[modelIdx].mid }
|
||||
metadata
|
||||
}]}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
|
@ -999,6 +1104,10 @@ function _applyState(s, sessionId) {
|
|||
while (_participantSessions.length < _models.length) _participantSessions.push(null);
|
||||
_parentSessionId = s.parentSessionId;
|
||||
_roundRobinIdx = s.roundRobinIdx || 0;
|
||||
if (_whisperTargetSessionId && !_participantTargetForSession(_whisperTargetSessionId)) {
|
||||
_whisperTargetSessionId = null;
|
||||
}
|
||||
setTimeout(_syncWhisperTargetUi, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1056,6 +1165,7 @@ const groupModule = {
|
|||
init, isActive, setActive, getMode, setMode, showModelPicker,
|
||||
startGroup, stopGroup, sendMessage, restoreState,
|
||||
getModels, getModelCount,
|
||||
setWhisperTarget, clearWhisperTarget, getWhisperTarget, getWhisperUserMetadata,
|
||||
};
|
||||
|
||||
export default groupModule;
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ async function _ensureDefaultPendingChat() {
|
|||
* @param {function} deps.getSessions - returns sessions array
|
||||
* @param {function} deps.getPendingChat - returns _pendingChat object
|
||||
* @param {function} deps.setPendingChat - sets _pendingChat object
|
||||
* @param {function} deps.isCurrentGroupChild - returns true for raw group participant chats
|
||||
* @param {function} deps.createDirectChat - creates a new direct chat session
|
||||
*/
|
||||
export function initModelPicker(deps) {
|
||||
|
|
@ -856,9 +857,9 @@ export function updateModelPicker() {
|
|||
if (!_deps) return;
|
||||
const label = document.getElementById('model-picker-label');
|
||||
if (!label) return;
|
||||
// Hide model picker when group chat is active
|
||||
// Hide model picker when group chat or a raw group participant whisper is active
|
||||
const wrap = document.getElementById('model-picker-wrap');
|
||||
if (window.groupModule && window.groupModule.isActive()) {
|
||||
if ((window.groupModule && window.groupModule.isActive()) || (_deps.isCurrentGroupChild && _deps.isCurrentGroupChild())) {
|
||||
if (wrap) { wrap.style.display = 'none'; }
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const API_BASE = window.location.origin;
|
|||
|
||||
let sessions = [];
|
||||
let currentSessionId = null;
|
||||
let _currentSessionDetails = null;
|
||||
const _groupChildSessions = new Map();
|
||||
let _sessionNavToken = 0;
|
||||
let _skipAutoSelect = false;
|
||||
let _suppressNextSessionLoading = false;
|
||||
|
|
@ -339,6 +341,49 @@ function _normalizeSessionsList(fetched) {
|
|||
return unique;
|
||||
}
|
||||
|
||||
function _indexGroupChildSessions(list) {
|
||||
_groupChildSessions.clear();
|
||||
for (const session of list || []) {
|
||||
const participants = _groupParticipantsForSession(session);
|
||||
participants.forEach(participant => {
|
||||
const id = String(participant.id || '');
|
||||
if (!id) return;
|
||||
_groupChildSessions.set(id, {
|
||||
id,
|
||||
name: participant.name || participant.model || `Participant ${(participant.index || 0) + 1}`,
|
||||
model: participant.model_id || participant.model || '',
|
||||
endpoint_url: participant.endpoint_url || session.endpoint_url || '',
|
||||
endpoint_id: participant.endpoint_id || '',
|
||||
parent_session_id: session.id,
|
||||
parent_name: session.name || '',
|
||||
is_group_participant_child: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _sessionDetailsForId(id) {
|
||||
const sid = String(id || '');
|
||||
return sessions.find(x => String(x.id) === sid) ||
|
||||
(_currentSessionDetails && String(_currentSessionDetails.id) === sid ? _currentSessionDetails : null) ||
|
||||
_groupChildInfoForId(sid) ||
|
||||
null;
|
||||
}
|
||||
|
||||
function _groupChildInfoForId(id) {
|
||||
const sid = String(id || '');
|
||||
const indexed = _groupChildSessions.get(sid);
|
||||
if (indexed) return indexed;
|
||||
if (
|
||||
_currentSessionDetails &&
|
||||
String(_currentSessionDetails.id) === sid &&
|
||||
_currentSessionDetails.is_group_participant_child
|
||||
) {
|
||||
return _currentSessionDetails;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _groupParticipantsForSession(session) {
|
||||
if (!session || !Array.isArray(session.group_participants)) return [];
|
||||
return session.group_participants.filter(participant => participant && participant.id);
|
||||
|
|
@ -375,8 +420,10 @@ function _createGroupParticipantsList(session, participants) {
|
|||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'group-participant-row';
|
||||
row.title = 'Open the parent group chat';
|
||||
row.setAttribute('aria-label', `Open group chat for ${participant.name || participant.model || 'participant'}`);
|
||||
row.dataset.groupParticipantId = String(participant.id);
|
||||
row.title = `Open raw chat for ${participant.name || participant.model || 'participant'}`;
|
||||
row.setAttribute('aria-label', `Open raw chat for ${participant.name || participant.model || 'participant'}`);
|
||||
row.setAttribute('aria-pressed', 'false');
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'group-participant-dot';
|
||||
|
|
@ -395,9 +442,12 @@ function _createGroupParticipantsList(session, participants) {
|
|||
row.appendChild(model);
|
||||
}
|
||||
|
||||
row.addEventListener('click', (e) => {
|
||||
row.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (currentSessionId !== session.id) selectSession(session.id);
|
||||
if (window.groupModule && window.groupModule.clearWhisperTarget) {
|
||||
window.groupModule.clearWhisperTarget();
|
||||
}
|
||||
await selectSession(participant.id, { keepSidebar: true });
|
||||
});
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
|
@ -1791,6 +1841,7 @@ export async function loadSessions() {
|
|||
fetched = await res.json();
|
||||
}
|
||||
sessions = _normalizeSessionsList(fetched);
|
||||
_indexGroupChildSessions(sessions);
|
||||
renderSessionList();
|
||||
|
||||
const sessionsSection = uiModule.el('sessions-section');
|
||||
|
|
@ -1837,6 +1888,8 @@ export async function loadSessions() {
|
|||
targetId = null;
|
||||
} else if (hashId && activeSessions.some(s => s.id === hashId)) {
|
||||
targetId = hashId;
|
||||
} else if (hashId && _groupChildSessions.has(String(hashId))) {
|
||||
targetId = hashId;
|
||||
} else if (currentSessionId && activeSessions.some(s => s.id === currentSessionId)) {
|
||||
targetId = currentSessionId;
|
||||
} else if (currentSessionId) {
|
||||
|
|
@ -1844,6 +1897,8 @@ export async function loadSessions() {
|
|||
targetId = currentSessionId;
|
||||
} else if (!_freshRootLoad && savedId && activeSessions.some(s => s.id === savedId)) {
|
||||
targetId = savedId;
|
||||
} else if (!_freshRootLoad && savedId && _groupChildSessions.has(String(savedId))) {
|
||||
targetId = savedId;
|
||||
} else if (!_freshRootLoad && !_skipAutoSelect && _realSessions.length > 0) {
|
||||
// Most-recent NON-transient session — skip Assistant / Tasks so the
|
||||
// auto-firing assistant doesn't become the apparent default chat.
|
||||
|
|
@ -1943,6 +1998,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
}
|
||||
currentSessionId = id;
|
||||
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
|
||||
_currentSessionDetails = _sessionDetailsForId(id);
|
||||
// Identify Assistant / task-output sessions so we don't "trap" the user
|
||||
// there on return. Skipped from both `lastSessionId` persistence and the
|
||||
// URL hash — the user complained that coming back to Odysseus kept
|
||||
|
|
@ -1959,6 +2015,13 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
if (presetsModule && presetsModule.onSessionSwitch) presetsModule.onSessionSwitch(id);
|
||||
} catch (e) {}
|
||||
const meta = sessions.find(s => s.id === id);
|
||||
const groupChildInfo = _groupChildInfoForId(id);
|
||||
if (groupChildInfo) {
|
||||
_pendingChat = null;
|
||||
}
|
||||
if (window._syncWhisperIndicator) {
|
||||
window._syncWhisperIndicator(!!groupChildInfo, groupChildInfo || null);
|
||||
}
|
||||
|
||||
// Detach any in-flight stream to background instead of aborting
|
||||
try {
|
||||
|
|
@ -2016,10 +2079,21 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
|
||||
const activeEl = document.querySelector(`.list-item[data-session-id="${id}"]`);
|
||||
if (activeEl) activeEl.classList.add('active-session');
|
||||
document.querySelectorAll('.group-participant-row.child-active').forEach(el => {
|
||||
el.classList.remove('child-active');
|
||||
el.setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
document.querySelectorAll('.group-participant-row').forEach(row => {
|
||||
const active = String(row.dataset.groupParticipantId || '') === String(id);
|
||||
if (active) {
|
||||
row.classList.add('child-active');
|
||||
row.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
const currentMetaEl = uiModule.el('current-meta');
|
||||
if (currentMetaEl) {
|
||||
currentMetaEl.textContent = meta ? meta.name : 'Odysseus Chat';
|
||||
currentMetaEl.textContent = meta ? meta.name : (groupChildInfo ? groupChildInfo.name : 'Odysseus Chat');
|
||||
}
|
||||
// Update model picker visibility
|
||||
updateModelPicker();
|
||||
|
|
@ -2067,6 +2141,19 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
total: data.total,
|
||||
has_more_before: !!data.has_more_before,
|
||||
};
|
||||
_currentSessionDetails = meta || {
|
||||
id,
|
||||
name: data.name || groupChildInfo?.name || 'Odysseus Chat',
|
||||
model: data.model || groupChildInfo?.model || '',
|
||||
endpoint_url: data.endpoint_url || groupChildInfo?.endpoint_url || '',
|
||||
endpoint_id: groupChildInfo?.endpoint_id || '',
|
||||
parent_session_id: groupChildInfo?.parent_session_id || null,
|
||||
group_parent_session_id: groupChildInfo?.parent_session_id || null,
|
||||
is_group_participant_child: !!groupChildInfo,
|
||||
};
|
||||
if (!meta && currentMetaEl) {
|
||||
currentMetaEl.textContent = _currentSessionDetails.name || 'Odysseus Chat';
|
||||
}
|
||||
// The model returned by /api/history is the authoritative one the
|
||||
// backend will use for this session. Write it back into the cached
|
||||
// session meta and refresh the picker so the displayed model can
|
||||
|
|
@ -2125,8 +2212,19 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
'OpenClaw');
|
||||
} else if (msgHistory.length) {
|
||||
for (const msg of msgHistory) {
|
||||
let renderMsg = msg;
|
||||
if (groupChildInfo && msg.role === 'assistant') {
|
||||
renderMsg = {
|
||||
...msg,
|
||||
metadata: {
|
||||
...(msg.metadata || {}),
|
||||
character_name: (msg.metadata && msg.metadata.character_name) || groupChildInfo.name,
|
||||
model: (msg.metadata && msg.metadata.model) || groupChildInfo.model || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
try {
|
||||
_renderHistoryMessage(msg, modelName);
|
||||
_renderHistoryMessage(renderMsg, modelName);
|
||||
} catch (e) {
|
||||
console.warn('Failed to render history message:', e, msg);
|
||||
}
|
||||
|
|
@ -2174,14 +2272,32 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
|
||||
}
|
||||
if (restoredGroupState) {
|
||||
if (window._syncWhisperIndicator) window._syncWhisperIndicator(false);
|
||||
if (window._syncGroupIndicator) window._syncGroupIndicator(true);
|
||||
if (window.groupModule && window.groupModule.clearWhisperTarget) {
|
||||
window.groupModule.clearWhisperTarget();
|
||||
}
|
||||
// Hide model picker for group sessions
|
||||
const _mpw = document.getElementById('model-picker-wrap');
|
||||
if (_mpw) _mpw.style.display = 'none';
|
||||
} else if (groupChildInfo) {
|
||||
if (window.groupModule && window.groupModule.isActive()) {
|
||||
// Child raw chats send through the normal session stream so the
|
||||
// backend can mirror them to the parent as whispers.
|
||||
window.groupModule.stopGroup();
|
||||
}
|
||||
if (window._syncGroupIndicator) window._syncGroupIndicator(false);
|
||||
if (window._syncWhisperIndicator) window._syncWhisperIndicator(true, groupChildInfo);
|
||||
const _mpw = document.getElementById('model-picker-wrap');
|
||||
if (_mpw) _mpw.style.display = 'none';
|
||||
} else if (window.groupModule && window.groupModule.isActive()) {
|
||||
// Switching away from group session — deactivate
|
||||
window.groupModule.stopGroup();
|
||||
if (window._syncGroupIndicator) window._syncGroupIndicator(false);
|
||||
if (window._syncWhisperIndicator) window._syncWhisperIndicator(false);
|
||||
updateModelPicker();
|
||||
} else if (window._syncWhisperIndicator) {
|
||||
window._syncWhisperIndicator(false);
|
||||
}
|
||||
|
||||
// Stop pulsing notification — user is now viewing this session
|
||||
|
|
@ -2307,6 +2423,7 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) {
|
|||
}
|
||||
|
||||
// Don't hit the API — just store the model info and prepare the UI
|
||||
if (window._syncWhisperIndicator) window._syncWhisperIndicator(false);
|
||||
_pendingChat = { url, modelId, endpointId, source: incomingSource };
|
||||
_pendingMaterializePromise = null;
|
||||
_skipAutoSelect = true;
|
||||
|
|
@ -2459,21 +2576,33 @@ export function preMaterializePendingSession() {
|
|||
|
||||
export function hasPendingChat() { return !!_pendingChat; }
|
||||
export function getPendingChat() { return _pendingChat; }
|
||||
export function clearPendingChat() { _pendingChat = null; }
|
||||
// Getters for external access
|
||||
export function getCurrentSessionId() {
|
||||
return currentSessionId;
|
||||
}
|
||||
|
||||
export function getCurrentGroupChildInfo() {
|
||||
return _groupChildInfoForId(currentSessionId);
|
||||
}
|
||||
|
||||
export function isCurrentGroupChild() {
|
||||
return !!getCurrentGroupChildInfo();
|
||||
}
|
||||
|
||||
export function isCurrentSessionIncognito() {
|
||||
return !!(currentSessionId && _isIncognitoSession(currentSessionId));
|
||||
}
|
||||
|
||||
export function getSessions() {
|
||||
if (_currentSessionDetails && !sessions.some(x => String(x.id) === String(_currentSessionDetails.id))) {
|
||||
return sessions.concat([_currentSessionDetails]);
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export function getCurrentModel() {
|
||||
const sess = sessions.find(x => x.id === currentSessionId);
|
||||
const sess = _sessionDetailsForId(currentSessionId);
|
||||
if (sess && sess.model) return sess.model;
|
||||
if (_pendingChat && _pendingChat.modelId) return _pendingChat.modelId;
|
||||
return null;
|
||||
|
|
@ -2482,7 +2611,7 @@ export function getCurrentModel() {
|
|||
/** Endpoint URL serving the current (or pending) session's model. Used to
|
||||
* decide whether a model is local (free) vs a billable cloud provider. */
|
||||
export function getCurrentEndpointUrl() {
|
||||
const sess = sessions.find(x => x.id === currentSessionId);
|
||||
const sess = _sessionDetailsForId(currentSessionId);
|
||||
if (sess && sess.endpoint_url) return sess.endpoint_url;
|
||||
if (_pendingChat && _pendingChat.url) return _pendingChat.url;
|
||||
return null;
|
||||
|
|
@ -2494,6 +2623,7 @@ export function setCurrentSessionId(id) {
|
|||
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
|
||||
if (!id) {
|
||||
_suppressNextSessionLoading = true;
|
||||
_currentSessionDetails = null;
|
||||
Storage.remove('lastSessionId');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => {
|
||||
|
|
@ -2879,9 +3009,10 @@ export function clearStreamComplete(sessionId) {
|
|||
function _initAllDropdowns() {
|
||||
initModelPicker({
|
||||
getCurrentSessionId: () => currentSessionId,
|
||||
getSessions: () => sessions,
|
||||
getSessions,
|
||||
getPendingChat: () => _pendingChat,
|
||||
setPendingChat: (v) => { _pendingChat = v; },
|
||||
isCurrentGroupChild,
|
||||
createDirectChat,
|
||||
});
|
||||
_initDropdownDismiss();
|
||||
|
|
@ -3747,7 +3878,10 @@ const sessionModule = {
|
|||
preMaterializePendingSession,
|
||||
hasPendingChat,
|
||||
getPendingChat,
|
||||
clearPendingChat,
|
||||
getCurrentSessionId,
|
||||
getCurrentGroupChildInfo,
|
||||
isCurrentGroupChild,
|
||||
getSessions,
|
||||
getCurrentModel,
|
||||
getCurrentEndpointUrl,
|
||||
|
|
|
|||
|
|
@ -8428,6 +8428,11 @@ a.chat-link:hover {
|
|||
background: color-mix(in srgb, var(--fg) 7%, transparent) !important;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.group-participant-row.child-active,
|
||||
.group-participant-row.whisper-active {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
.group-participant-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
|
@ -8435,6 +8440,11 @@ a.chat-link:hover {
|
|||
border: 1px solid color-mix(in srgb, var(--fg) 28%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.group-participant-row.child-active .group-participant-dot,
|
||||
.group-participant-row.whisper-active .group-participant-dot {
|
||||
background: var(--accent, var(--red));
|
||||
border-color: var(--accent, var(--red));
|
||||
}
|
||||
.group-participant-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
@ -30299,11 +30309,19 @@ button .spinner-whirlpool {
|
|||
.msg-group .role {
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg-whisper {
|
||||
border-color: color-mix(in srgb, #8b5cf6 32%, var(--bubble-border, var(--border))) !important;
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, #8b5cf6 58%, transparent);
|
||||
}
|
||||
#group-toggle-btn.active,
|
||||
.overflow-menu-item#overflow-group-btn.active {
|
||||
color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 12%, transparent);
|
||||
}
|
||||
#whisper-toggle-btn.active {
|
||||
color: #8b5cf6;
|
||||
background: color-mix(in srgb, #8b5cf6 14%, transparent);
|
||||
}
|
||||
/* Group model picker — match app theme */
|
||||
#group-model-picker .modal-content {
|
||||
background: var(--bg);
|
||||
|
|
|
|||
|
|
@ -196,13 +196,53 @@ def test_list_sessions_hides_group_participants(monkeypatch):
|
|||
assert not set(child_ids) & returned_ids
|
||||
assert parent["is_group_parent"] is True
|
||||
assert parent["group_participants"] == [
|
||||
{"id": child_ids[0], "index": 0, "name": "Athena", "model": "Llama 3"},
|
||||
{"id": child_ids[1], "index": 1, "name": "Mistral", "model": "Mistral"},
|
||||
{
|
||||
"id": child_ids[0],
|
||||
"index": 0,
|
||||
"name": "Athena",
|
||||
"model": "Llama 3",
|
||||
"model_id": "llama3",
|
||||
"endpoint_url": "http://localhost:11434",
|
||||
"endpoint_id": "local",
|
||||
},
|
||||
{
|
||||
"id": child_ids[1],
|
||||
"index": 1,
|
||||
"name": "Mistral",
|
||||
"model": "Mistral",
|
||||
"model_id": "mistral",
|
||||
"endpoint_url": "http://localhost:11434",
|
||||
"endpoint_id": None,
|
||||
},
|
||||
]
|
||||
assert normal["is_group_parent"] is False
|
||||
assert normal["group_participants"] == []
|
||||
|
||||
|
||||
def test_group_child_whisper_context_resolves_parent_for_owner(monkeypatch):
|
||||
_reset_db()
|
||||
parent_id = str(uuid.uuid4())
|
||||
child_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
_add_session(parent_id, name="[GRP] Athena, Mistral")
|
||||
for session_id in child_ids:
|
||||
_add_session(session_id, name="[GRP] participant")
|
||||
_add_group_state(parent_id, child_ids)
|
||||
|
||||
import routes.chat_routes as cr
|
||||
|
||||
monkeypatch.setattr(cr, "SessionLocal", _TS)
|
||||
ctx = cr._group_child_whisper_context(child_ids[0], "alice")
|
||||
|
||||
assert ctx == {
|
||||
"parent_session_id": parent_id,
|
||||
"participant_session_id": child_ids[0],
|
||||
"participant_index": 0,
|
||||
"participant_name": "Athena",
|
||||
"participant_model": "llama3",
|
||||
}
|
||||
assert cr._group_child_whisper_context(child_ids[0], "bob") is None
|
||||
|
||||
|
||||
def test_group_parent_folder_move_cascades_and_child_move_is_blocked(monkeypatch):
|
||||
_reset_db()
|
||||
parent_id = str(uuid.uuid4())
|
||||
|
|
|
|||
|
|
@ -9,6 +9,34 @@ SESSIONS_SOURCE = (
|
|||
Path(__file__).resolve().parent.parent / "static" / "js" / "sessions.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
APP_SOURCE = (
|
||||
Path(__file__).resolve().parent.parent / "static" / "app.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
RENDERER_SOURCE = (
|
||||
Path(__file__).resolve().parent.parent / "static" / "js" / "chatRenderer.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
CHAT_ROUTES_SOURCE = (
|
||||
Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
MODEL_PICKER_SOURCE = (
|
||||
Path(__file__).resolve().parent.parent / "static" / "js" / "modelPicker.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
CHAT_SOURCE = (
|
||||
Path(__file__).resolve().parent.parent / "static" / "js" / "chat.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
INDEX_SOURCE = (
|
||||
Path(__file__).resolve().parent.parent / "static" / "index.html"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
STYLE_SOURCE = (
|
||||
Path(__file__).resolve().parent.parent / "static" / "style.css"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_group_session_sidebar_cache_uses_safe_json_loader():
|
||||
assert "import Storage from './storage.js';" in SOURCE
|
||||
|
|
@ -30,3 +58,66 @@ def test_group_participants_render_as_non_session_sidebar_rows():
|
|||
assert "group-participant-row" in SESSIONS_SOURCE
|
||||
assert "row.setAttribute('aria-label'" in SESSIONS_SOURCE
|
||||
assert "row.setAttribute('data-session-id'" not in SESSIONS_SOURCE
|
||||
|
||||
|
||||
def test_group_participant_rows_open_raw_child_sessions():
|
||||
assert "row.dataset.groupParticipantId" in SESSIONS_SOURCE
|
||||
assert "Open raw chat for" in SESSIONS_SOURCE
|
||||
assert "await selectSession(participant.id, { keepSidebar: true })" in SESSIONS_SOURCE
|
||||
assert "window.groupModule.setWhisperTarget(participant.id)" not in SESSIONS_SOURCE
|
||||
assert "window.groupModule.clearWhisperTarget()" in SESSIONS_SOURCE
|
||||
assert "_groupChildSessions" in SESSIONS_SOURCE
|
||||
assert "_currentSessionDetails" in SESSIONS_SOURCE
|
||||
assert "_groupChildSessions.has(String(hashId))" in SESSIONS_SOURCE
|
||||
|
||||
|
||||
def test_group_child_raw_chat_keeps_whisper_context_without_model_picker():
|
||||
assert 'id="whisper-toggle-btn"' in INDEX_SOURCE
|
||||
assert "#whisper-toggle-btn.active" in STYLE_SOURCE
|
||||
assert "window._syncWhisperIndicator = _syncWhisperIndicator" in APP_SOURCE
|
||||
assert "sessionModule.getCurrentGroupChildInfo" in APP_SOURCE
|
||||
assert "Send whisper to" in APP_SOURCE
|
||||
assert "export function getCurrentGroupChildInfo()" in SESSIONS_SOURCE
|
||||
assert "export function isCurrentGroupChild()" in SESSIONS_SOURCE
|
||||
assert "export function clearPendingChat()" in SESSIONS_SOURCE
|
||||
assert "if (groupChildInfo) {\n _pendingChat = null;" in SESSIONS_SOURCE
|
||||
assert "window._syncWhisperIndicator(true, groupChildInfo)" in SESSIONS_SOURCE
|
||||
assert "isCurrentGroupChild," in SESSIONS_SOURCE
|
||||
assert "deps.isCurrentGroupChild" in MODEL_PICKER_SOURCE
|
||||
assert "sessionModule.clearPendingChat" in CHAT_SOURCE
|
||||
|
||||
|
||||
def test_group_child_raw_chat_uses_participant_alias_for_assistant_labels():
|
||||
assert "childMeta.character_name = childMeta.character_name || groupChildInfo.name" in SESSIONS_SOURCE
|
||||
assert "const groupChildAlias = groupChildInfo && groupChildInfo.name" in CHAT_SOURCE
|
||||
assert "holder._characterName = _charNameInit || ''" in CHAT_SOURCE
|
||||
assert "json.character_name || holder._characterName" in CHAT_SOURCE
|
||||
assert "if (metadata?.character_name) roleEl.textContent = metadata.character_name" in RENDERER_SOURCE
|
||||
|
||||
|
||||
def test_group_internal_sends_do_not_trigger_child_mirroring():
|
||||
assert "let _whisperTargetSessionId = null;" in SOURCE
|
||||
assert "export function setWhisperTarget(sessionId)" in SOURCE
|
||||
assert "export function getWhisperUserMetadata()" in SOURCE
|
||||
assert "async function _sendWhisper(msg, box, target)" in SOURCE
|
||||
assert "_streamToHolder(target.index, target.sessionId, msg, holder, ac, { whisper: true })" in SOURCE
|
||||
whisper_block = SOURCE.split("async function _sendWhisper", 1)[1].split("async function _sendParallel", 1)[0]
|
||||
assert "_syncAllResponses" not in whisper_block
|
||||
assert "Private whisper from the user" not in SOURCE
|
||||
assert "This is a private direct message from the user to you" in SOURCE
|
||||
assert "fd.append('group_internal', 'true')" in SOURCE
|
||||
assert 'group_internal = str(form_data.get("group_internal", "")' in CHAT_ROUTES_SOURCE
|
||||
assert "_group_child_whisper_context(session, _user)" in CHAT_ROUTES_SOURCE
|
||||
assert "_mirror_group_child_user_message" in CHAT_ROUTES_SOURCE
|
||||
assert "_mirror_group_child_assistant_message" in CHAT_ROUTES_SOURCE
|
||||
assert "group_whisper: true" in SOURCE
|
||||
assert "whisper_to" in SOURCE
|
||||
assert "whisper_from" in SOURCE
|
||||
|
||||
|
||||
def test_group_submit_and_history_render_whisper_metadata():
|
||||
assert "groupModule.getWhisperUserMetadata" in APP_SOURCE
|
||||
assert "chatRenderer.addMessage('user', msg, null, userMetadata)" in APP_SOURCE
|
||||
assert "metadata?.group_whisper" in RENDERER_SOURCE
|
||||
assert "'Whisper to ' + metadata.whisper_to" in RENDERER_SOURCE
|
||||
assert "'Whisper from ' + metadata.whisper_from" in RENDERER_SOURCE
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue