115 lines
4.7 KiB
Python
115 lines
4.7 KiB
Python
"""
|
|
Hermes WebUI -- Session model and in-memory session store.
|
|
"""
|
|
import collections
|
|
import json
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from api.config import (
|
|
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
|
|
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL
|
|
)
|
|
from api.workspace import get_last_workspace
|
|
|
|
|
|
def _write_session_index():
|
|
"""Rebuild the session index file for O(1) future reads."""
|
|
entries = []
|
|
for p in SESSION_DIR.glob('*.json'):
|
|
if p.name.startswith('_'): continue
|
|
try:
|
|
s = Session.load(p.stem)
|
|
if s: entries.append(s.compact())
|
|
except Exception:
|
|
pass
|
|
with LOCK:
|
|
for s in SESSIONS.values():
|
|
if not any(e['session_id'] == s.session_id for e in entries):
|
|
entries.append(s.compact())
|
|
entries.sort(key=lambda s: s['updated_at'], reverse=True)
|
|
SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
|
|
|
|
class Session:
|
|
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, **kwargs):
|
|
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time()
|
|
@property
|
|
def path(self): return SESSION_DIR / f'{self.session_id}.json'
|
|
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
|
|
@classmethod
|
|
def load(cls, sid):
|
|
p = SESSION_DIR / f'{sid}.json'
|
|
if not p.exists(): return None
|
|
return cls(**json.loads(p.read_text(encoding='utf-8')))
|
|
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at}
|
|
|
|
def get_session(sid):
|
|
with LOCK:
|
|
if sid in SESSIONS:
|
|
SESSIONS.move_to_end(sid) # LRU: mark as recently used
|
|
return SESSIONS[sid]
|
|
s = Session.load(sid)
|
|
if s:
|
|
with LOCK:
|
|
SESSIONS[sid] = s
|
|
SESSIONS.move_to_end(sid)
|
|
while len(SESSIONS) > SESSIONS_MAX:
|
|
SESSIONS.popitem(last=False) # evict least recently used
|
|
return s
|
|
raise KeyError(sid)
|
|
|
|
def new_session(workspace=None, model=None):
|
|
s = Session(workspace=workspace or get_last_workspace(), model=model or DEFAULT_MODEL)
|
|
with LOCK:
|
|
SESSIONS[s.session_id] = s
|
|
SESSIONS.move_to_end(s.session_id)
|
|
while len(SESSIONS) > SESSIONS_MAX:
|
|
SESSIONS.popitem(last=False)
|
|
s.save()
|
|
return s
|
|
|
|
def all_sessions():
|
|
# Phase C: try index first for O(1) read; fall back to full scan
|
|
if SESSION_INDEX_FILE.exists():
|
|
try:
|
|
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
|
# Overlay any in-memory sessions that may be newer than the index
|
|
index_map = {s['session_id']: s for s in index}
|
|
with LOCK:
|
|
for s in SESSIONS.values():
|
|
index_map[s.session_id] = s.compact()
|
|
result = sorted(index_map.values(), key=lambda s: s['updated_at'], reverse=True)
|
|
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
|
|
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
|
|
return result
|
|
except Exception:
|
|
pass # fall through to full scan
|
|
# Full scan fallback
|
|
out = []
|
|
for p in SESSION_DIR.glob('*.json'):
|
|
if p.name.startswith('_'): continue
|
|
try:
|
|
s = Session.load(p.stem)
|
|
if s: out.append(s)
|
|
except Exception:
|
|
pass
|
|
for s in SESSIONS.values():
|
|
if all(s.session_id != x.session_id for x in out): out.append(s)
|
|
out.sort(key=lambda s: s.updated_at, reverse=True)
|
|
return [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
|
|
|
|
|
|
def title_from(messages, fallback='Untitled'):
|
|
"""Derive a session title from the first user message."""
|
|
for m in messages:
|
|
if m.get('role') == 'user':
|
|
c = m.get('content', '')
|
|
if isinstance(c, list):
|
|
c = ' '.join(p.get('text', '') for p in c if isinstance(p, dict) and p.get('type') == 'text')
|
|
text = str(c).strip()
|
|
if text:
|
|
return text[:64]
|
|
return fallback
|