diff --git a/core/database.py b/core/database.py index 65ad40316..cb644d946 100644 --- a/core/database.py +++ b/core/database.py @@ -251,6 +251,16 @@ def to_dict(self): 'crew_member_id': self.crew_member_id, } + +class GroupChatState(TimestampMixin, Base): + __tablename__ = "group_chat_states" + + parent_session_id = Column(String, ForeignKey("sessions.id", ondelete="CASCADE"), primary_key=True, index=True) + owner = Column(String, nullable=True, index=True) + mode = Column(String, nullable=False, default="parallel") + state = Column(JSON, nullable=False, default=dict) + + class ChatMessage(Base): """ SQLAlchemy model for ChatMessage table. diff --git a/core/session_manager.py b/core/session_manager.py index eeb9c2a16..3d3c11c5e 100644 --- a/core/session_manager.py +++ b/core/session_manager.py @@ -587,39 +587,15 @@ def create_session( def delete_session(self, session_id: str) -> bool: """Permanently delete a session and all its messages.""" db = SessionLocal() + image_paths = [] try: - try: - from src.session_image_cleanup import cleanup_session_images - cleanup_session_images(session_id, db=db) - except Exception as e: - logger.warning(f"Image cleanup failed while deleting session {session_id}: {e}") - - # Detach documents so they survive as orphans in the library - db.query(DbDocument).filter(DbDocument.session_id == session_id).update( - {DbDocument.session_id: None}, synchronize_session=False - ) - - # Delete messages - db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete() - - # Delete session - db_session = db.query(DbSession).filter(DbSession.id == session_id).first() - if db_session: - db.delete(db_session) - - # Drop the in-memory copy even when there is no DB row. A "ghost" - # session lives only here (never persisted, or its row was removed - # out-of-band); without this it can never be cleared and keeps - # 404ing on every operation (issue #1044). - removed_in_memory = self.sessions.pop(session_id, None) is not None - - if db_session or removed_in_memory: - # Commit the document-detach / message-delete above (a no-op when - # the ghost had no rows) together with the session delete. - db.commit() - logger.info(f"Deleted session {session_id}") - return True - return False + deleted = self._stage_session_deletion(db, session_id, image_paths) + if not deleted: + return False + db.commit() + self._finalize_session_deletions([session_id], image_paths) + logger.info(f"Deleted session {session_id}") + return True except Exception as e: logger.error(f"Error deleting session: {e}") @@ -628,6 +604,41 @@ def delete_session(self, session_id: str) -> bool: finally: db.close() + def _stage_session_deletion(self, db, session_id: str, image_paths: list) -> bool: + """Stage one session deletion in a caller-owned transaction. + + This method does not commit, evict in-memory state, or unlink files. + Errors intentionally propagate so callers deleting related sessions can + roll the entire unit of work back. + """ + from src.session_image_cleanup import prepare_session_image_cleanup + + _, pending_paths = prepare_session_image_cleanup(session_id, db) + image_paths.extend(pending_paths) + + db.query(DbDocument).filter(DbDocument.session_id == session_id).update( + {DbDocument.session_id: None}, synchronize_session=False + ) + db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete( + synchronize_session=False + ) + + db_session = db.query(DbSession).filter(DbSession.id == session_id).first() + if db_session: + db.delete(db_session) + + # A ghost session may exist only in memory. Keep it there until commit + # succeeds, but still report it as a deletable target. + return bool(db_session or session_id in self.sessions) + + def _finalize_session_deletions(self, session_ids, image_paths: list) -> None: + """Apply non-transactional cleanup after the DB commit succeeds.""" + from src.session_image_cleanup import unlink_session_image_paths + + for session_id in session_ids: + self.sessions.pop(session_id, None) + unlink_session_image_paths(image_paths, ",".join(str(sid) for sid in session_ids)) + # ------------------------------------------------------------------ # Session updates # ------------------------------------------------------------------ diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 0b181796f..cf3d19b0a 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -40,10 +40,11 @@ from src.session_search import search_session_messages from src.prompt_security import untrusted_context_message from core.exceptions import SessionNotFoundError -from src.auth_helpers import effective_user, get_current_user +from src.auth_helpers import effective_user, get_current_user, owner_filter 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 @@ -75,6 +76,112 @@ _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) + q = owner_filter(q, GroupChatState, 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_failure_status(chunk: str) -> Optional[int]: """Extract a provider status without retaining provider-supplied detail.""" @@ -745,6 +852,22 @@ async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str defer_context_shaping=foreground_policy.enabled, ) + # Direct sends to a hidden group participant are whispers. Mirror the + # visible user turn into the parent transcript, just like the streaming + # route does. Internal group orchestration already writes to the parent + # and must opt out to avoid duplicate turns. + group_child_whisper = ( + None + if chat_request.group_internal + else _group_child_whisper_context(session, ctx.user) + ) + if group_child_whisper: + _mirror_group_child_user_message( + session_manager, + group_child_whisper, + ctx.preprocessed.user_content, + ) + # Research injection research_blocked_by_policy = ( tool_policy.blocks("trigger_research") @@ -839,6 +962,14 @@ async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str }, ) sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md)) + if group_child_whisper: + _mirror_group_child_assistant_message( + session_manager, + group_child_whisper, + reply, + sess.model, + None, + ) from core.database import update_session_last_accessed update_session_last_accessed(session) @@ -904,6 +1035,7 @@ async def chat_stream(request: Request) -> StreamingResponse: 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' tool_approval_id = ( @@ -1418,6 +1550,17 @@ async def chat_stream(request: Request) -> StreamingResponse: # 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: @@ -2128,6 +2271,17 @@ def _commit_chat_compaction(candidate_index: int) -> bool: ) 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, @@ -2392,6 +2546,17 @@ def _commit_chat_compaction(candidate_index: int) -> bool: ) 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 b1d79f7fe..2e92e366f 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -10,8 +10,8 @@ from core.session_manager import SessionManager from core.models import ChatMessage from src.request_models import SessionResponse -from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive -from src.auth_helpers import effective_user, _auth_disabled, owner_filter +from core.database import Session as DbSession, GroupChatState, SessionLocal, Document, GalleryImage, utcnow_naive +from src.auth_helpers import get_current_user, effective_user, _auth_disabled, owner_filter from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs from src.session_actions import is_session_recently_active from src.upload_handler import reserve_message_upload_references @@ -173,6 +173,241 @@ def _persist_session_headers(session_id: str, headers: dict | None) -> None: db.close() +_GROUP_CHAT_MODES = {"parallel", "round-robin"} +_GROUP_CHAT_MAX_PARTICIPANTS = 8 + + +def _group_state_str(value, max_len: int = 4096) -> str | None: + if value is None: + return None + text = str(value) + return text[:max_len] + + +def _normalize_group_character(value) -> dict | None: + if not isinstance(value, dict): + return None + normalized = {} + character_id = _group_state_str(value.get("characterId"), 256) + character_name = _group_state_str(value.get("characterName"), 1024) + character_prompt = _group_state_str(value.get("characterPrompt"), 50000) + if character_id: + normalized["characterId"] = character_id + if character_name: + normalized["characterName"] = character_name + if character_prompt: + normalized["characterPrompt"] = character_prompt + return normalized or None + + +def _normalize_group_model(value) -> dict: + if not isinstance(value, dict): + raise HTTPException(400, "Invalid group model") + mid = _group_state_str(value.get("mid"), 1024) + url = _group_state_str(value.get("url"), 2048) + if not mid or not url: + raise HTTPException(400, "Group model is missing its model id or endpoint URL") + + normalized = { + "mid": mid, + "url": url, + "display": _group_state_str(value.get("display") or mid, 1024), + } + endpoint_id = _group_state_str(value.get("endpointId"), 256) + endpoint_name = _group_state_str(value.get("epName"), 1024) + group_name = _group_state_str(value.get("_groupName"), 1024) + character = _normalize_group_character(value.get("character")) + if endpoint_id: + normalized["endpointId"] = endpoint_id + if endpoint_name: + normalized["epName"] = endpoint_name + if group_name: + normalized["_groupName"] = group_name + if character: + normalized["character"] = character + return normalized + + +def _normalize_group_state(raw_state, parent_session_id: str) -> dict: + if isinstance(raw_state, dict) and isinstance(raw_state.get("group_state"), dict): + raw_state = raw_state["group_state"] + if not isinstance(raw_state, dict): + raise HTTPException(400, "Invalid group chat state") + + mode = raw_state.get("mode") + if mode not in _GROUP_CHAT_MODES: + mode = "parallel" + + raw_models = raw_state.get("models") + if not isinstance(raw_models, list): + raise HTTPException(400, "Group chat state is missing models") + models = [ + _normalize_group_model(model) + for model in raw_models[:_GROUP_CHAT_MAX_PARTICIPANTS] + ] + if len(models) < 2: + raise HTTPException(400, "Group chat state must include at least two models") + + raw_participants = raw_state.get("participantSessions", []) + if not isinstance(raw_participants, list): + raise HTTPException(400, "Invalid group participant session list") + participant_sessions = [] + for value in raw_participants[:len(models)]: + participant_sessions.append(_group_state_str(value, 256) if value else None) + while len(participant_sessions) < len(models): + participant_sessions.append(None) + + try: + round_robin_idx = max(0, int(raw_state.get("roundRobinIdx", 0))) + except (TypeError, ValueError): + round_robin_idx = 0 + + return { + "active": True, + "mode": mode, + "models": models, + "participantSessions": participant_sessions, + "parentSessionId": parent_session_id, + "roundRobinIdx": round_robin_idx, + } + + +def _group_participant_ids_from_state(state) -> set[str]: + if not isinstance(state, dict): + return set() + raw_participants = state.get("participantSessions") + if not isinstance(raw_participants, list): + return set() + return {str(session_id) for session_id in raw_participants if session_id} + + +def _group_state_query_for_user(db, user): + q = db.query(GroupChatState.parent_session_id, GroupChatState.state) + return owner_filter(q, GroupChatState, user) + + +def _group_session_links_for_user(db, user) -> tuple[set[str], set[str]]: + parent_ids: set[str] = set() + participant_ids: set[str] = set() + for parent_id, state in _group_state_query_for_user(db, user).all(): + if parent_id: + parent_ids.add(parent_id) + participant_ids.update(_group_participant_ids_from_state(state)) + return parent_ids, participant_ids + + +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_state_str(value, 1024) + if text: + return text + return f"Participant {idx + 1}" + + +def _group_participants_for_user(db, user) -> dict[str, list[dict]]: + participants_by_parent: dict[str, list[dict]] = {} + for parent_id, state in _group_state_query_for_user(db, user).all(): + if not parent_id or not isinstance(state, dict): + continue + raw_participants = state.get("participantSessions") + if not isinstance(raw_participants, list): + continue + models = state.get("models") + if not isinstance(models, list): + models = [] + + participants = [] + for idx, session_id in enumerate(raw_participants): + if not session_id: + continue + model = models[idx] if idx < len(models) and isinstance(models[idx], dict) else {} + participants.append({ + "id": str(session_id), + "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 + return participants_by_parent + + +def _group_parent_for_participant(db, session_id: str, user) -> str | None: + for parent_id, state in _group_state_query_for_user(db, user).all(): + if session_id in _group_participant_ids_from_state(state): + return parent_id + return None + + +def _reject_group_participant_direct_action(db, session_id: str, user, action: str) -> None: + if _group_parent_for_participant(db, session_id, user): + raise HTTPException(403, f"{action} the parent group chat instead") + + +def _set_group_participant_folders(db, participant_ids: set[str], folder: str | None, user) -> None: + if not participant_ids: + return + q = db.query(DbSession).filter(DbSession.id.in_(participant_ids)) + q = owner_filter(q, DbSession, user) + now = utcnow_naive() + for participant in q.all(): + participant.folder = folder + participant.updated_at = now + + +def _sync_group_participant_folder(db, parent_session_id: str, folder: str | None, user) -> None: + row = db.query(GroupChatState.state).filter(GroupChatState.parent_session_id == parent_session_id).first() + if row is None: + return + _set_group_participant_folders(db, _group_participant_ids_from_state(row.state), folder, user) + + +def _delete_session_with_group_children(session_manager, session_id: str, user) -> bool: + db = SessionLocal() + target_ids: list[str] = [] + image_paths = [] + try: + q = db.query(GroupChatState).filter(GroupChatState.parent_session_id == session_id) + q = owner_filter(q, GroupChatState, user) + group_state = q.first() + participant_ids = ( + sorted(_group_participant_ids_from_state(group_state.state)) + if group_state + else [] + ) + target_ids = list(dict.fromkeys([*participant_ids, session_id])) + + for target_id in target_ids: + if not session_manager._stage_session_deletion(db, target_id, image_paths): + raise RuntimeError(f"Session deletion target not found: {target_id}") + + if group_state: + db.delete(group_state) + db.commit() + except Exception: + db.rollback() + logger.exception("Failed atomic deletion of session group rooted at %s", session_id) + raise + finally: + db.close() + + session_manager._finalize_session_deletions(target_ids, image_paths) + return True + + _HIDDEN_SYSTEM_SESSION_NAMES = { "[Task] Chat Sessions Tidy", "[Task] Documents Tidy", @@ -269,6 +504,8 @@ def list_sessions(request: Request): last_msg_map = {} mode_map = {} msg_count_map = {} + group_parent_ids, group_participant_ids = _group_session_links_for_user(db, user) + group_participants_map = _group_participants_for_user(db, user) q = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False) q = owner_filter(q, DbSession, user) rows = q.all() @@ -319,9 +556,12 @@ def list_sessions(request: Request): "has_documents": s.id in doc_session_ids, "has_images": s.id in img_session_ids, "mode": mode_map.get(s.id), - "message_count": msg_count_map.get(s.id, 0)} + "message_count": msg_count_map.get(s.id, 0), + "is_group_parent": s.id in group_parent_ids, + "group_participants": group_participants_map.get(s.id, [])} for s in user_sessions.values() if not s.archived + and s.id not in group_participant_ids and (s.name or "").strip() not in ("Nobody", "Incognito") and (s.name or "").strip() not in _HIDDEN_SYSTEM_SESSION_NAMES] @@ -478,12 +718,24 @@ def rename_session( if folder is not None: db = SessionLocal() try: + user = effective_user(request) + parent_id = _group_parent_for_participant(db, sid, user) + if parent_id: + raise HTTPException(403, "Move the parent group chat instead") db_session = db.query(DbSession).filter(DbSession.id == sid).first() if db_session: - db_session.folder = folder if folder else None + folder_value = folder if folder else None + db_session.folder = folder_value db_session.updated_at = utcnow_naive() + _sync_group_participant_folder(db, sid, folder_value, user) db.commit() - result["folder"] = folder if folder else None + result["folder"] = folder_value + except HTTPException: + db.rollback() + raise + except Exception: + db.rollback() + raise finally: db.close() # Switch model/endpoint mid-session @@ -568,6 +820,74 @@ async def inject_messages(request: Request, sid: str): session_manager.save_sessions() return {"ok": True, "count": len(messages)} + @router.put("/session/{sid}/group_state") + async def save_group_state(request: Request, sid: str): + """Persist the group-chat participant/model state for the parent session.""" + _verify_session_owner(request, sid) + state = _normalize_group_state(await request.json(), sid) + user = effective_user(request) + participant_ids = { + participant_id + for participant_id in state["participantSessions"] + if participant_id + } + + db = SessionLocal() + try: + if participant_ids: + rows = db.query(DbSession.id, DbSession.owner).filter(DbSession.id.in_(participant_ids)).all() + owner_by_id = {row.id: row.owner for row in rows} + if set(owner_by_id) != participant_ids: + raise HTTPException(400, "Group participant session not found") + if user and any(owner not in (user, None) for owner in owner_by_id.values()): + raise HTTPException(404, "Group participant session not found") + + group_state = db.query(GroupChatState).filter(GroupChatState.parent_session_id == sid).first() + if group_state is None: + group_state = GroupChatState(parent_session_id=sid) + db.add(group_state) + group_state.owner = user + group_state.mode = state["mode"] + group_state.state = state + group_state.updated_at = utcnow_naive() + parent_folder = db.query(DbSession.folder).filter(DbSession.id == sid).first() + _set_group_participant_folders( + db, + participant_ids, + parent_folder.folder if parent_folder else None, + user, + ) + db.commit() + return {"ok": True, "group_state": state} + except HTTPException: + db.rollback() + raise + except Exception: + db.rollback() + raise + finally: + db.close() + + @router.get("/session/{sid}/group_state") + def get_group_state(request: Request, sid: str): + """Return persisted group-chat state for a parent session, if one exists.""" + _verify_session_owner(request, sid) + user = effective_user(request) + db = SessionLocal() + try: + group_state = db.query(GroupChatState).filter(GroupChatState.parent_session_id == sid).first() + if group_state is None: + return {"ok": False, "group_state": None} + if user and group_state.owner and group_state.owner != user: + raise HTTPException(404, "Group chat state not found") + try: + state = _normalize_group_state(group_state.state, sid) + except HTTPException: + return {"ok": False, "group_state": None} + return {"ok": True, "group_state": state} + finally: + db.close() + @router.post("/session/{sid}/delete") def delete_session_beacon(request: Request, sid: str): """Delete session via POST (for navigator.sendBeacon on page close).""" @@ -586,17 +906,19 @@ async def bulk_delete_sessions(request: Request): for sid in ids: try: _verify_session_owner(request, sid, session_manager) + user = effective_user(request) # Enforce "starred" protection consistent with single-session delete db = SessionLocal() try: + _reject_group_participant_direct_action(db, sid, user, "Delete") db_sess = db.query(DbSession).filter(DbSession.id == sid).first() if db_sess and db_sess.is_important: continue finally: db.close() - if session_manager.delete_session(sid): + if _delete_session_with_group_children(session_manager, sid, user): deleted_count += 1 except Exception: pass @@ -607,9 +929,11 @@ def delete_session(request: Request, sid: str): """Permanently delete a session and all its messages.""" _verify_session_owner(request, sid, session_manager) try: + user = effective_user(request) # Block deletion of starred/favorited sessions db = SessionLocal() try: + _reject_group_participant_direct_action(db, sid, user, "Delete") db_sess = db.query(DbSession).filter(DbSession.id == sid).first() if db_sess and db_sess.is_important: raise HTTPException( @@ -620,7 +944,7 @@ def delete_session(request: Request, sid: str): db.close() # Delete the session and all its messages - if session_manager.delete_session(sid): + if _delete_session_with_group_children(session_manager, sid, user): return {"status": "deleted"} else: raise HTTPException(404, "Session not found") @@ -698,8 +1022,10 @@ def archive_session(request: Request, sid: str): session_manager.get_session(sid) # Archive the session + user = effective_user(request) db = SessionLocal() try: + _reject_group_participant_direct_action(db, sid, user, "Archive") db_session = db.query(DbSession).filter(DbSession.id == sid).first() if db_session: db_session.archived = True @@ -731,8 +1057,10 @@ def archive_session(request: Request, sid: str): def unarchive_session(request: Request, sid: str): """Restore an archived session back to the active session list.""" _verify_session_owner(request, sid) + user = effective_user(request) db = SessionLocal() try: + _reject_group_participant_direct_action(db, sid, user, "Restore") db_session = db.query(DbSession).filter(DbSession.id == sid).first() if not db_session: raise HTTPException(404, f"Session {sid} not found") @@ -767,6 +1095,9 @@ def list_archived_sessions(request: Request, search: str = "", offset: int = 0, if not user: raise HTTPException(403, "Authentication required") q = q.filter(DbSession.owner == user) + _, group_participant_ids = _group_session_links_for_user(db, user) + if group_participant_ids: + q = q.filter(~DbSession.id.in_(group_participant_ids)) if search: safe_search = search.replace('%', r'\%').replace('_', r'\_') q = q.filter(DbSession.name.ilike(f"%{safe_search}%", escape='\\')) @@ -1060,6 +1391,8 @@ def auto_sort_sessions(request: Request, skip_llm: bool = False): db = SessionLocal() deleted_empty = 0 deleted_throwaway = 0 + group_parent_ids: set[str] = set() + group_participant_ids: set[str] = set() # Names that indicate a throwaway/test session (case-insensitive exact or prefix match) _THROWAWAY_NAMES = { "test", "testing", "asdf", "asd", "hello", "hi", "hey", @@ -1077,6 +1410,8 @@ def auto_sort_sessions(request: Request, skip_llm: bool = False): elif not single_user_mode: rows_q = rows_q.filter(DbSession.owner == user) rows = rows_q.limit(2000).all() + group_parent_ids, group_participant_ids = _group_session_links_for_user(db, user) + protected_group_ids = group_parent_ids | group_participant_ids folder_map = {r.id: r.folder for r in rows} # Precompute per-session message counts in TWO aggregate queries # instead of 1–3 queries PER session — with many chats the per-row @@ -1089,6 +1424,8 @@ def auto_sort_sessions(request: Request, skip_llm: bool = False): ) cleanup_now = utcnow_naive() for row in rows: + if row.id in protected_group_ids: + continue # Never delete important sessions if getattr(row, 'is_important', False): continue @@ -1167,6 +1504,8 @@ def auto_sort_sessions(request: Request, skip_llm: bool = False): for s in user_sessions.values(): if s.archived or s.name == "Incognito": continue + if s.id in group_participant_ids: + continue if folder_map.get(s.id): # Already in a folder — skip on this pass. continue @@ -1305,6 +1644,7 @@ def _loads_lenient(s): if db_session: db_session.folder = folder_name db_session.updated_at = utcnow_naive() + _sync_group_participant_folder(db, sid, folder_name, user) updated += 1 db.commit() except Exception as e: diff --git a/src/request_models.py b/src/request_models.py index f29e9fbab..6d5a42e10 100644 --- a/src/request_models.py +++ b/src/request_models.py @@ -12,6 +12,10 @@ class ChatRequest(BaseModel): use_research: Optional[bool] = Field(default=False, description="Enable deep research") time_filter: Optional[str] = Field(default=None, description="Time filter for search") preset_id: Optional[str] = Field(default=None, description="Preset identifier") + group_internal: Optional[bool] = Field( + default=False, + description="Internal group-chat turn; suppress parent whisper mirroring", + ) selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID") @field_validator('message') diff --git a/src/session_actions.py b/src/session_actions.py index 072bb4c06..301aa928c 100644 --- a/src/session_actions.py +++ b/src/session_actions.py @@ -53,6 +53,15 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo return False +def _group_participant_ids_from_state(state) -> set[str]: + if not isinstance(state, dict): + return set() + participants = state.get("participantSessions") + if not isinstance(participants, list): + return set() + return {str(session_id) for session_id in participants if session_id} + + async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str: """Run session cleanup + (optional) AI folder sort for the given owner. @@ -65,7 +74,8 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo Returns a human-readable summary of what was done. """ - from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg + from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg, GroupChatState + from src.auth_helpers import owner_filter from src.llm_core import llm_call_async from src.task_endpoint import resolve_task_endpoint @@ -75,13 +85,25 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo deleted_empty = 0 deleted_throwaway = 0 - rows = db.query(DbSession).filter( - DbSession.archived == False, - *([DbSession.owner == owner] if owner else []), + rows = owner_filter( + db.query(DbSession).filter(DbSession.archived == False), + DbSession, + owner, ).all() + group_query = db.query(GroupChatState.parent_session_id, GroupChatState.state) + group_query = owner_filter(group_query, GroupChatState, owner) + group_parent_to_participants = {} + group_participant_ids: set[str] = set() + for parent_id, state in group_query.all(): + participants = _group_participant_ids_from_state(state) + group_parent_to_participants[parent_id] = participants + group_participant_ids.update(participants) + protected_group_ids = set(group_parent_to_participants) | group_participant_ids cleanup_now = _utcnow_naive() for row in rows: + if row.id in protected_group_ids: + continue if getattr(row, 'is_important', False): continue created_at = _as_naive_utc(row.created_at or row.updated_at) or _utcnow_naive() @@ -141,15 +163,18 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions") # ── Phase 2: AI folder assignment ── - remaining = db.query(DbSession).filter( - DbSession.archived == False, - *([DbSession.owner == owner] if owner else []), + remaining = owner_filter( + db.query(DbSession).filter(DbSession.archived == False), + DbSession, + owner, ).all() session_list = [] for row in remaining: if row.name == "Incognito": continue + if row.id in group_participant_ids: + continue session_list.append({ "id": row.id, "name": row.name or "(unnamed)", @@ -240,6 +265,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo if db_sess: db_sess.folder = folder_name db_sess.updated_at = _utcnow_naive() + participant_ids = group_parent_to_participants.get(full_id, set()) + if participant_ids: + child_query = db.query(DbSession).filter(DbSession.id.in_(participant_ids)) + child_query = owner_filter(child_query, DbSession, owner) + for child in child_query.all(): + child.folder = folder_name + child.updated_at = _utcnow_naive() updated += 1 db.commit() diff --git a/src/session_image_cleanup.py b/src/session_image_cleanup.py index 280169c75..8cb499983 100644 --- a/src/session_image_cleanup.py +++ b/src/session_image_cleanup.py @@ -81,44 +81,66 @@ def session_image_refs(db, session_id: str) -> tuple[set[str], set[str]]: return image_ids, filenames +def prepare_session_image_cleanup(session_id: str, db) -> tuple[int, list[Path]]: + """Stage gallery soft-deletes and return files to unlink after commit. + + Database work deliberately raises to the caller so a larger session-delete + transaction can roll back as one unit. Filesystem deletion is deferred + because it cannot be rolled back with the database transaction. + """ + _, GalleryImage, _ = _database_models() + image_ids, filenames = session_image_refs(db, session_id) + query = db.query(GalleryImage).filter(GalleryImage.session_id == session_id) + if image_ids or filenames: + from sqlalchemy import or_ + + clauses = [GalleryImage.session_id == session_id] + if image_ids: + clauses.append(GalleryImage.id.in_(list(image_ids))) + if filenames: + clauses.append(GalleryImage.filename.in_(list(filenames))) + query = db.query(GalleryImage).filter(or_(*clauses)) + + images = query.all() + paths: list[Path] = [] + for img in images: + img.is_active = False + if img.filename: + path = _generated_image_path_for_cleanup(img.filename) + if path: + paths.append(path) + return len(images), paths + + +def unlink_session_image_paths(paths: list[Path], session_id: str) -> None: + """Best-effort removal of generated files after a successful DB commit.""" + for path in dict.fromkeys(paths): + if not path.exists(): + continue + try: + path.unlink() + except Exception as exc: + logger.warning( + "Could not remove generated image %s for deleted session %s: %s", + path.name, + session_id, + exc, + ) + + def cleanup_session_images(session_id: str, db=None) -> int: """Soft-delete Gallery rows and unlink generated files owned by a chat.""" - _, GalleryImage, SessionLocal = _database_models() + _, _, SessionLocal = _database_models() owns_db = db is None db = db or SessionLocal() try: - image_ids, filenames = session_image_refs(db, session_id) - query = db.query(GalleryImage).filter(GalleryImage.session_id == session_id) - if image_ids or filenames: - from sqlalchemy import or_ - - clauses = [GalleryImage.session_id == session_id] - if image_ids: - clauses.append(GalleryImage.id.in_(list(image_ids))) - if filenames: - clauses.append(GalleryImage.filename.in_(list(filenames))) - query = db.query(GalleryImage).filter(or_(*clauses)) - - images = query.all() - removed = 0 - for img in images: - img.is_active = False - if img.filename: - path = _generated_image_path_for_cleanup(img.filename) - if path and path.exists(): - try: - path.unlink() - except Exception as exc: - logger.warning( - "Could not remove generated image %s for deleted session %s: %s", - img.filename, - session_id, - exc, - ) - removed += 1 - - if owns_db and images: + removed, paths = prepare_session_image_cleanup(session_id, db) + if owns_db and removed: db.commit() + # Retain the standalone helper's historical behavior for callers that + # supply a transaction. Atomic session deletion uses the prepare/unlink + # pair directly so it can defer this irreversible step until commit. + unlink_session_image_paths(paths, session_id) return removed except Exception as exc: if owns_db: diff --git a/src/session_search.py b/src/session_search.py index d8b994fa8..a17a1d840 100644 --- a/src/session_search.py +++ b/src/session_search.py @@ -11,6 +11,7 @@ from sqlalchemy import text from core.database import ChatMessage as DBChatMessage +from core.database import GroupChatState from core.database import Session as DBSession from core.database import SessionLocal @@ -134,6 +135,30 @@ def _owner_filter(query, owner: str | None, include_legacy_owner: bool): return query.filter((DBSession.owner == owner) | (DBSession.owner.is_(None))) +def _group_participant_ids_from_state(state) -> set[str]: + if not isinstance(state, dict): + return set() + participants = state.get("participantSessions") + if not isinstance(participants, list): + return set() + return {str(session_id) for session_id in participants if session_id} + + +def _hidden_group_participant_ids(db, owner: str | None, restrict_owner: bool, include_legacy_owner: bool) -> set[str]: + q = db.query(GroupChatState.state) + if restrict_owner: + if owner is None: + q = q.filter(GroupChatState.owner.is_(None)) + elif include_legacy_owner: + q = q.filter((GroupChatState.owner == owner) | (GroupChatState.owner.is_(None))) + else: + q = q.filter(GroupChatState.owner == owner) + participant_ids: set[str] = set() + for (state,) in q.all(): + participant_ids.update(_group_participant_ids_from_state(state)) + return participant_ids + + def _context_for_message(db, msg: DBChatMessage, count: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: if count <= 0 or not msg.timestamp: return [], [] @@ -210,6 +235,9 @@ def _search_like( q = q.filter(~DBSession.name.like("SFT trace batch%")) if restrict_owner: q = _owner_filter(q, owner, include_legacy_owner) + hidden_participant_ids = _hidden_group_participant_ids(db, owner, restrict_owner, include_legacy_owner) + if hidden_participant_ids: + q = q.filter(~DBChatMessage.session_id.in_(hidden_participant_ids)) rows = q.order_by(DBChatMessage.timestamp.desc()).limit(limit).all() shaped = ((msg, session_name, _snippet(msg.content or "", query)) for msg, session_name in rows) return _rows_to_results(db, shaped, query, context_messages) @@ -278,6 +306,8 @@ def _search_fts( """ ) + hidden_participant_ids = _hidden_group_participant_ids(db, owner, restrict_owner, include_legacy_owner) + try: hits = db.execute(sql, params).fetchall() except Exception as e: @@ -293,6 +323,8 @@ def _search_fts( found = by_id.get(hit[0]) if found: msg, session_name = found + if msg.session_id in hidden_participant_ids: + continue rows.append((msg, session_name, hit[1] or "")) return _rows_to_results(db, rows, query, context_messages) diff --git a/static/app.js b/static/app.js index 426be5f66..aae437676 100644 --- a/static/app.js +++ b/static/app.js @@ -29,6 +29,7 @@ import voiceRecorderModule from './js/voiceRecorder.js'; import censorModule from './js/censor.js'; import galleryModule from './js/gallery.js'; import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js'; +import { syncWhisperIndicatorAccessibility } from './js/whisperIndicator.js'; import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1'; import calendarModule from './js/calendar.js'; import notesModule from './js/notes.js'; @@ -908,7 +909,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); @@ -974,6 +976,34 @@ 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); + syncWhisperIndicatorAccessibility(btn, active, target); + 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() { @@ -2044,6 +2074,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(); @@ -2575,6 +2606,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 = ''; @@ -3866,7 +3910,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; @@ -3921,6 +3966,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') { @@ -3943,9 +3998,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 { @@ -3983,15 +4042,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 4dd4c6795..62d389976 100644 --- a/static/index.html +++ b/static/index.html @@ -1180,6 +1180,10 @@

Odysseus

Group +