Hermes WebUI v0.1.0 — initial public release
This commit is contained in:
114
api/models.py
Normal file
114
api/models.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user