# routes/session_routes.py import re import html import json import uuid from datetime import datetime from fastapi import APIRouter, Form, HTTPException, Response, Request import logging 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 get_current_user, effective_user, _auth_disabled from src.session_actions import is_session_recently_active def _sanitize_export_filename(name: str) -> str: """Return a conservative filename safe for Content-Disposition.""" name = name if isinstance(name, str) else "" name = re.sub(r"[^A-Za-z0-9._-]", "_", name) return name[:128] # Blind-compare helper sessions are created with this name prefix. Their real # model must never surface in the session list / sidebar — otherwise a blind # comparison can be de-anonymized before the user votes (issue #1285). COMPARE_SESSION_PREFIX = "[CMP] " def _public_model(name: str, model: str) -> str: """Blank out the real model of blind-compare helper sessions so the session list can't be used to map a neutral pane label ("Model A") back to its model. The Compare UI tracks models client-side, so hiding it here costs the sidebar nothing. See issue #1285.""" if (name or "").startswith(COMPARE_SESSION_PREFIX): return "" return model def _content_to_text(content) -> str: """Flatten a message's content to plain text for text-based exports. History entries carry three shapes: a plain string, a multimodal list of content blocks (vision/image attachments), or None (assistant turns that persisted only native tool_calls). The txt/html/md exporters join and string-munge this value, so a list crashed the export (TypeError on join, AttributeError on .replace) and None rendered as the literal "None". Coerce to the text blocks, returning "" for anything without text. """ if isinstance(content, str): return content if isinstance(content, list): return "\n".join( b.get("text", "") for b in content if isinstance(b, dict) and b.get("text") ) return "" def _message_role(message) -> str: if isinstance(message, ChatMessage): return message.role or "" if isinstance(message, dict): return message.get("role", "") or "" return getattr(message, "role", "") or "" def _message_text(message) -> str: if isinstance(message, ChatMessage): content = message.content elif isinstance(message, dict): content = message.get("content") else: content = getattr(message, "content", None) return _content_to_text(content) def _message_metadata(message) -> dict: if isinstance(message, ChatMessage): metadata = message.metadata elif isinstance(message, dict): metadata = message.get("metadata") else: metadata = getattr(message, "metadata", None) return metadata if isinstance(metadata, dict) else {} def _reject_compact_during_active_run(session_id: str) -> None: from src import agent_runs if agent_runs.is_active(session_id): raise HTTPException(409, "Session has an active run; try compacting after it finishes") def _verify_session_owner(request: Request, session_id: str, session_manager=None): """Verify the current user owns the session, honoring single-user modes. Authenticated requests must match the stored DB or in-memory owner. When auth is disabled and no user is present, treat the app as single-user mode: verify that the session exists, but do not compare its stored owner. This keeps QA/dev instances with AUTH_ENABLED=false from rejecting owner-stamped rows created while auth was previously enabled. """ user = effective_user(request) if not user and not _auth_disabled(): raise HTTPException(401, "Authentication required") db = SessionLocal() try: row = db.query(DbSession.owner).filter(DbSession.id == session_id).first() finally: db.close() if row is not None: if user and row.owner != user: raise HTTPException(404, f"Session {session_id} not found") return # No DB row — allow the caller to act on an in-memory ghost they own. if session_manager is not None: ghost = getattr(session_manager, "sessions", {}).get(session_id) if ghost is not None and (not user or getattr(ghost, "owner", None) == user): return raise HTTPException(404, f"Session {session_id} not found") logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["sessions"]) def _current_user_is_admin(request: Request, user: str | None) -> bool: if not user: return False auth_mgr = getattr(request.app.state, "auth_manager", None) is_admin = getattr(auth_mgr, "is_admin", None) if not callable(is_admin): return False try: return bool(is_admin(user)) except Exception: return False def _reject_raw_endpoint_url_for_non_admin( request: Request, user: str | None, endpoint_id: str | None, endpoint_url: str | None, ) -> None: """Require registered endpoints for signed-in non-admin session changes.""" if endpoint_id and endpoint_id.strip(): return if not endpoint_url: return # Raw URLs make the server dial whatever host the request supplies. For # non-admin users, require a saved endpoint row so normal owner scoping and # endpoint validation have already happened. if user and not _current_user_is_admin(request, user): raise HTTPException(403, "Choose a registered model endpoint") def _persist_session_headers(session_id: str, headers: dict | None) -> None: """Persist endpoint auth headers for DB-backed session metadata.""" db = SessionLocal() try: db_session = db.query(DbSession).filter(DbSession.id == session_id).first() if db_session: db_session.headers = headers or {} db_session.updated_at = datetime.utcnow() db.commit() except Exception: db.rollback() raise finally: db.close() _HIDDEN_SYSTEM_SESSION_NAMES = { "[Task] Chat Sessions Tidy", "[Task] Documents Tidy", "[Task] Memory Tidy", "[Task] Research Tidy", "[Task] Email Mark Boundaries", "[Task] Email Tags", "[Task] Skills Audit", } def _pick_endpoint_for_sort(owner=None): """Pick model endpoint for auto-sort LLM call — uses utility endpoint setting, falls back to default.""" from src.endpoint_resolver import resolve_endpoint # Try utility endpoint first (what the user configured for background tasks) url, model, headers = resolve_endpoint("utility", owner=owner) if url and model: return url, model, headers # Fall back to task endpoint try: from src.task_endpoint import resolve_task_endpoint url, model, headers = resolve_task_endpoint(owner=owner) if url and model: return url, model, headers except Exception: pass # Fall back to default url, model, headers = resolve_endpoint("default", owner=owner) if url and model: return url, model, headers return None, None, None def setup_session_routes(session_manager: SessionManager, config: dict, webhook_manager=None): """Setup session routes with the provided manager and config""" REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", 20) OPENAI_API_KEY = config.get("OPENAI_API_KEY") SESSIONS_FILE = config.get("SESSIONS_FILE") @router.get("/sessions") def list_sessions(request: Request): user = effective_user(request) # Lazy purge: incognito sessions are ephemeral by design — wipe leftovers # from the DB and session_manager so they vanish on the next page refresh. # BUT: skip sessions that were created within the last 10 minutes. # Without that guard, the purge nukes the active "Nobody" session on the # very first /api/sessions call after creation, killing the in-flight # chat. The frontend's own _cleanupIncognitoSessions handler knows which # session is current and won't delete the live one — this server-side # purge exists only to catch ghosts the frontend missed (tab close, # crash). Only clean up rows old enough to be definitely orphaned. try: from datetime import datetime as _dt, timedelta as _td _cutoff = _dt.utcnow() - _td(minutes=10) _purge_db = SessionLocal() try: from core.database import ChatMessage as _DbMsg _ghosts = _purge_db.query(DbSession).filter( DbSession.name.in_(("Nobody", "Incognito")), DbSession.created_at < _cutoff, ).all() for _g in _ghosts: _purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete() _purge_db.delete(_g) if hasattr(session_manager, "delete_session"): try: session_manager.delete_session(_g.id) except Exception: pass if _ghosts: _purge_db.commit() finally: _purge_db.close() except Exception: pass user_sessions = session_manager.get_sessions_for_user(user) # Fetch folder info from DB for each session db = SessionLocal() try: folder_map = {} token_map = {} important_map = {} created_map = {} updated_map = {} last_msg_map = {} mode_map = {} msg_count_map = {} rows = 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, DbSession.owner == user).all() for row in rows: folder_map[row.id] = row.folder token_map[row.id] = (row.total_input_tokens or 0) + (row.total_output_tokens or 0) important_map[row.id] = row.is_important or False created_map[row.id] = row.created_at.isoformat() if row.created_at else None updated_map[row.id] = row.updated_at.isoformat() if row.updated_at else None # Fall back to updated_at then created_at so sessions that # predate the column (or have no messages) still sort sanely. last_msg_map[row.id] = ( row.last_message_at.isoformat() if row.last_message_at else (row.updated_at.isoformat() if row.updated_at else (row.created_at.isoformat() if row.created_at else None)) ) mode_map[row.id] = row.mode msg_count_map[row.id] = row.message_count or 0 # Sessions with active documents that have content from sqlalchemy import func doc_session_ids = set( r[0] for r in db.query(Document.session_id) .filter(Document.is_active == True, Document.current_content != None, func.trim(Document.current_content) != "", Document.owner == user) .distinct().all() ) img_session_ids = set( r[0] for r in db.query(GalleryImage.session_id) .filter(GalleryImage.session_id != None, GalleryImage.owner == user) .distinct().all() ) finally: db.close() sessions = [{"id": s.id, "name": s.name, "model": _public_model(s.name, s.model), "endpoint_url": s.endpoint_url, "rag": s.rag, "archived": s.archived, "folder": folder_map.get(s.id), "total_tokens": token_map.get(s.id, 0), "is_important": important_map.get(s.id, False), "created_at": created_map.get(s.id), "updated_at": updated_map.get(s.id), "last_message_at": last_msg_map.get(s.id), "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)} for s in user_sessions.values() if not s.archived and (s.name or "").strip() not in ("Nobody", "Incognito") and (s.name or "").strip() not in _HIDDEN_SYSTEM_SESSION_NAMES] return sessions @router.post("/session", response_model=SessionResponse) def create_session( request: Request, name: str = Form(""), endpoint_url: str = Form(""), model: str = Form(""), rag: str = Form(None), skip_validation: str = Form(None), api_key: str = Form(""), endpoint_id: str = Form(""), ): skip_val = str(skip_validation).lower() == "true" user = get_current_user(request) endpoint_api_key = "" endpoint_base_url = "" _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url) if endpoint_id and endpoint_id.strip(): from core.database import ModelEndpoint from src.auth_helpers import owner_filter from src.endpoint_resolver import build_chat_url, normalize_base _db = SessionLocal() try: q = _db.query(ModelEndpoint).filter( ModelEndpoint.id == endpoint_id.strip(), ModelEndpoint.is_enabled == True, ) if user: q = owner_filter(q, ModelEndpoint, user) endpoint_row = q.first() if not endpoint_row: raise HTTPException(400, "Model endpoint no longer exists") endpoint_base_url = endpoint_row.base_url or "" endpoint_api_key = endpoint_row.api_key or "" endpoint_url = build_chat_url(normalize_base(endpoint_base_url)) finally: _db.close() if not endpoint_url and not skip_val: raise HTTPException(400, "endpoint_url is required (choose from /api/models)") model_to_use = model request_api_key = api_key.strip() if api_key else "" effective_api_key = request_api_key or endpoint_api_key validation_headers = None if effective_api_key: from src.endpoint_resolver import build_headers validation_headers = build_headers(effective_api_key, endpoint_base_url or endpoint_url) if skip_val: # skip_validation = trust the caller and do NOT probe /v1/models. # Used for custom endpoints AND for bare placeholder sessions with no # model at all (e.g. an email reply draft just needs a session to live # in). Probing here was 400-ing those with "Cannot reach /v1/models". pass elif not model_to_use: from src.llm_core import list_model_ids ids = list_model_ids( endpoint_url, timeout=REQUEST_TIMEOUT, headers=validation_headers, owner=user, endpoint_id=endpoint_id.strip() if endpoint_id else None, ) if not ids: raise HTTPException(400, "Cannot reach /v1/models") # Default to the first CHAT model — endpoints often list embedding/ # tts/whisper models first (e.g. text-embedding-ada-002), which # can't hold a conversation. _NON_CHAT = ("text-embedding", "embedding", "tts-", "whisper", "text-moderation", "moderation-", "dall-e", "rerank") chat_ids = [m for m in ids if not any(p in m.lower() for p in _NON_CHAT)] model_to_use = (chat_ids or ids)[0] else: from src.llm_core import list_model_ids import os as _os req_base = _os.path.basename(model_to_use.rstrip("/")) avail = list_model_ids( endpoint_url, timeout=REQUEST_TIMEOUT, headers=validation_headers, owner=user, endpoint_id=endpoint_id.strip() if endpoint_id else None, ) if not avail: raise HTTPException(400, "Cannot reach /v1/models") if model_to_use not in avail: found = None for a in avail: if _os.path.basename(a.rstrip("/")) == req_base: found = a break if not found: raise HTTPException(400, f"Model not found at server. Available: {', '.join(avail)}") model_to_use = found sid = str(uuid.uuid4()) user = effective_user(request) session = session_manager.create_session( session_id=sid, name=name or "", endpoint_url=endpoint_url or "", model=model_to_use, rag=str(rag).lower() == "true" if rag else False, owner=user, ) # Set auth headers for custom API-key endpoints resolved_key = request_api_key resolved_base = endpoint_url if not resolved_key and endpoint_api_key: resolved_key = endpoint_api_key resolved_base = endpoint_base_url if resolved_key: from src.endpoint_resolver import build_headers session.headers = build_headers(resolved_key, resolved_base) _persist_session_headers(sid, session.headers) # Fire webhook (sync-safe) if webhook_manager: webhook_manager.fire_and_forget("session.created", { "session_id": sid, "name": session.name, "model": model_to_use, }) # Fire event for automation tasks from src.event_bus import fire_event fire_event("session_created", user) return SessionResponse( id=sid, name=session.name, model=model_to_use, rag=str(rag).lower() == "true" if rag else False, archived=False ) @router.patch("/session/{sid}") def rename_session( request: Request, sid: str, name: str = Form(None), folder: str = Form(None), model: str = Form(None), endpoint_url: str = Form(None), endpoint_id: str = Form(None), ): _verify_session_owner(request, sid) try: session = session_manager.get_session(sid) except KeyError: raise HTTPException(404, f"Session {sid} not found") result = {"id": sid} if name is not None: session_manager.update_session_name(sid, name) result["name"] = name # Update folder assignment if folder is not None: db = SessionLocal() try: db_session = db.query(DbSession).filter(DbSession.id == sid).first() if db_session: db_session.folder = folder if folder else None db_session.updated_at = datetime.utcnow() db.commit() result["folder"] = folder if folder else None finally: db.close() # Switch model/endpoint mid-session if model is not None and endpoint_url is not None: user = get_current_user(request) _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url) endpoint_api_key = "" endpoint_base_url = "" if endpoint_id: from core.database import ModelEndpoint from src.auth_helpers import owner_filter from src.endpoint_resolver import build_chat_url, normalize_base _db = SessionLocal() try: q = _db.query(ModelEndpoint).filter( ModelEndpoint.id == endpoint_id, ModelEndpoint.is_enabled == True, ) if user: q = owner_filter(q, ModelEndpoint, user) ep = q.first() if not ep: raise HTTPException(400, "Model endpoint no longer exists") endpoint_base_url = ep.base_url or "" endpoint_api_key = ep.api_key or "" endpoint_url = build_chat_url(normalize_base(endpoint_base_url)) finally: _db.close() session.model = model session.endpoint_url = endpoint_url # Update auth headers from the endpoint's stored API key if endpoint_api_key: from src.endpoint_resolver import build_headers session.headers = build_headers(endpoint_api_key, endpoint_base_url) else: session.headers = {} # Persist to DB db = SessionLocal() try: db_session = db.query(DbSession).filter(DbSession.id == sid).first() if db_session: db_session.model = model db_session.endpoint_url = endpoint_url db_session.headers = session.headers or {} db_session.updated_at = datetime.utcnow() db.commit() finally: db.close() result["model"] = model result["endpoint_url"] = endpoint_url return result @router.post("/session/{sid}/inject_messages") async def inject_messages(request: Request, sid: str): """Bulk-inject messages into a session's history (for group chat sync).""" _verify_session_owner(request, sid) try: sess = session_manager.get_session(sid) except KeyError: raise HTTPException(404, f"Session {sid} not found") body = await request.json() messages = body.get("messages", []) from core.models import ChatMessage for m in messages: sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata"))) session_manager.save_sessions() return {"ok": True, "count": len(messages)} @router.post("/session/{sid}/delete") def delete_session_beacon(request: Request, sid: str): """Delete session via POST (for navigator.sendBeacon on page close).""" return delete_session(request, sid) @router.post("/sessions/bulk-delete") async def bulk_delete_sessions(request: Request): """Delete multiple sessions (for compare cleanup via sendBeacon).""" from core.database import ChatMessage as _CM try: body = await request.json() ids = body.get("ids", []) except Exception: ids = [] deleted_count = 0 for sid in ids: try: _verify_session_owner(request, sid, session_manager) # Enforce "starred" protection consistent with single-session delete db = SessionLocal() try: 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): deleted_count += 1 except Exception: pass return {"deleted": deleted_count} @router.delete("/session/{sid}") def delete_session(request: Request, sid: str): """Permanently delete a session and all its messages.""" _verify_session_owner(request, sid, session_manager) try: # Block deletion of starred/favorited sessions db = SessionLocal() try: db_sess = db.query(DbSession).filter(DbSession.id == sid).first() if db_sess and db_sess.is_important: raise HTTPException( status_code=403, detail={"error": "SESSION_STARRED", "message": "Unstar the session before deleting it"} ) finally: db.close() # Delete the session and all its messages if session_manager.delete_session(sid): return {"status": "deleted"} else: raise HTTPException(404, "Session not found") except HTTPException: raise except Exception as e: logger.error(f"Error deleting session {sid}: {e}") raise HTTPException( status_code=500, detail={ "error": "SESSION_DELETE_ERROR", "message": "Failed to delete session" } ) @router.delete("/sessions/all") def delete_all_sessions(request: Request): """Admin only: permanently delete ALL sessions and their messages.""" from core.middleware import require_admin require_admin(request) db = SessionLocal() try: from core.database import ChatMessage as DbChatMessage count = db.query(DbSession).count() db.query(DbChatMessage).delete() db.query(DbSession).delete() db.commit() session_manager.sessions.clear() logger.info(f"Admin deleted all {count} sessions") return {"status": "deleted", "count": count} except Exception as e: db.rollback() logger.error(f"Error deleting all sessions: {e}") raise HTTPException(500, "Failed to delete sessions") finally: db.close() @router.post("/session/{sid}/archive") def archive_session(request: Request, sid: str): """Archive a session, keeping its data but removing it from active sessions.""" _verify_session_owner(request, sid) try: # First check if session exists session_manager.get_session(sid) # Archive the session db = SessionLocal() try: db_session = db.query(DbSession).filter(DbSession.id == sid).first() if db_session: db_session.archived = True db_session.updated_at = datetime.utcnow() db.commit() # Update in memory if it exists if sid in session_manager.sessions: session_manager.sessions[sid].archived = True logger.info(f"Archived session {sid}") return {"status": "archived"} else: raise HTTPException(404, f"Session {sid} not found") except HTTPException: raise except Exception as e: db.rollback() logger.error(f"Error archiving session {sid}: {e}") raise HTTPException(500, "Failed to archive session") finally: db.close() except KeyError: raise HTTPException(404, f"Session '{sid}' not found") @router.post("/session/{sid}/unarchive") def unarchive_session(request: Request, sid: str): """Restore an archived session back to the active session list.""" _verify_session_owner(request, sid) db = SessionLocal() try: db_session = db.query(DbSession).filter(DbSession.id == sid).first() if not db_session: raise HTTPException(404, f"Session {sid} not found") db_session.archived = False db_session.updated_at = datetime.utcnow() db.commit() # Reload into session manager so it appears in the active list try: if sid in session_manager.sessions: session_manager.sessions[sid].archived = False else: session_manager._load_session_from_db(sid) except Exception: pass # Non-fatal — session will load on next access return {"status": "unarchived"} except HTTPException: raise except Exception as e: db.rollback() logger.error(f"Error unarchiving session {sid}: {e}") raise HTTPException(500, "Failed to unarchive session") finally: db.close() @router.get("/sessions/archived") def list_archived_sessions(request: Request, search: str = "", offset: int = 0, limit: int = 20, sort: str = "recent", model: str = ""): """List archived sessions for the archive browser.""" user = effective_user(request) db = SessionLocal() try: q = db.query(DbSession).filter(DbSession.archived == True) if not user: raise HTTPException(403, "Authentication required") q = q.filter(DbSession.owner == user) if search: safe_search = search.replace('%', r'\%').replace('_', r'\_') q = q.filter(DbSession.name.ilike(f"%{safe_search}%", escape='\\')) if model: # Contains match (mirrors the name filter above). The old # f"%{model}" was a SUFFIX-only match, so filtering by "gpt-4" # dropped "gpt-4o" and over-matched on shared suffixes; it also # left LIKE wildcards in the user value unescaped. safe_model = model.replace('%', r'\%').replace('_', r'\_') q = q.filter(DbSession.model.ilike(f"%{safe_model}%", escape='\\')) total = q.count() sort_map = { "recent": DbSession.updated_at.desc(), "oldest": DbSession.updated_at.asc(), "most-messages": DbSession.message_count.desc().nulls_last(), "alpha": DbSession.name.asc(), } order = sort_map.get(sort, DbSession.updated_at.desc()) rows = q.order_by(order).offset(offset).limit(limit).all() sessions = [] for s in rows: sessions.append({ "id": s.id, "name": s.name, "model": s.model, "message_count": s.message_count or 0, "created_at": s.created_at.isoformat() if s.created_at else None, "updated_at": s.updated_at.isoformat() if s.updated_at else None, "is_important": s.is_important, }) return {"sessions": sessions, "total": total} finally: db.close() @router.get("/history/{sid}") def get_history(request: Request, sid: str): _verify_session_owner(request, sid) try: session = session_manager.get_session(sid) except KeyError: raise HTTPException(404, f"Session {sid} not found") return {"history": [msg.to_dict() for msg in session.history]} @router.get("/session/{sid}/export") def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""): """Export conversation history as a downloadable file. Supported formats: md (markdown), txt (plain text), json, html """ _verify_session_owner(request, sid) try: session = session_manager.get_session(sid) except KeyError: raise HTTPException(404, f"Session {sid} not found") safe_name = re.sub(r'[^\w\-_]', '_', session.name) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = _sanitize_export_filename(filename) if fmt == "json": import json as _json data = { "name": session.name, "model": session.model, "exported": datetime.now().isoformat(), "messages": [{"role": m.role, "content": m.content} for m in session.history], } out_name = filename or f"conversation_{safe_name}_{timestamp}.json" return Response( content=_json.dumps(data, indent=2, ensure_ascii=False), media_type="application/json", headers={"Content-Disposition": f"attachment; filename={out_name}"}, ) if fmt == "txt": lines = [] for m in session.history: lines.append(f"[{m.role.upper()}]") lines.append(_content_to_text(m.content)) lines.append("") out_name = filename or f"conversation_{safe_name}_{timestamp}.txt" return Response( content="\n".join(lines), media_type="text/plain", headers={"Content-Disposition": f"attachment; filename={out_name}"}, ) if fmt == "html": safe_title = html.escape(session.name or "") html_parts = [ "
", f"