from __future__ import annotations import json, time from datetime import datetime from pathlib import Path from typing import Any, Callable class UserStateStore: """Server-side persistence for workspace/settings/favorites. Keeps file persistence concerns out of the FastAPI assembly module. """ def __init__(self, root: Path, validator: Callable[[str], bool], max_favorites: int = 300): self.root = Path(root) self.validator = validator self.max_favorites = int(max_favorites) def _require_user(self, user_id: str) -> str: uid = str(user_id or "").strip() if not self.validator(uid): raise ValueError("invalid user_id") return uid def workspace_state_file(self, user_id: str) -> Path: return self.root / f"{self._require_user(user_id)}.workspace.json" def sanitize_workspace_sessions(self, items: Any) -> list[dict[str, Any]]: out=[] for x in items if isinstance(items,list) else []: if not isinstance(x,dict): continue thread_id=str(x.get("thread_id") or "")[:200] if not thread_id: continue messages=[] for m in (x.get("messages") if isinstance(x.get("messages"),list) else [])[-80:]: if not isinstance(m,dict): continue messages.append({ "role":"assistant" if m.get("role")=="assistant" else "user", "text":str(m.get("text") or "")[:50000], "meta":str(m.get("meta") or "")[:1000], "time":int(m.get("time") or int(time.time()*1000)), }) out.append({ "thread_id":thread_id, "title":str(x.get("title") or "")[:500], "messages":messages, "created_at":int(x.get("created_at") or int(time.time()*1000)), "updated_at":int(x.get("updated_at") or int(time.time()*1000)), }) out.sort(key=lambda x:int(x.get("updated_at") or 0), reverse=True) return out[:100] def sanitize_workspace_settings(self, value: Any) -> dict[str, Any]: if not isinstance(value,dict): return {} allowed={"fontSize","enterToSend","refreshSeconds","accent","defaultView","compact","reduceMotion","longitudeRange","timeDisplay","exportFormat","answerDetail","historyLimit","confirmDownload","messageActionTrigger","sidebarToolsCollapsed"} return {str(k):v for k,v in value.items() if k in allowed} def read_workspace(self, user_id: str) -> dict[str, Any]: path=self.workspace_state_file(user_id) if not path.exists(): return {"sessions":[],"settings":{},"updated_at":""} try: raw=json.loads(path.read_text(encoding="utf-8")) except Exception: return {"sessions":[],"settings":{},"updated_at":""} return { "sessions":self.sanitize_workspace_sessions(raw.get("sessions",[])), "settings":self.sanitize_workspace_settings(raw.get("settings",{})), "updated_at":str(raw.get("updated_at") or ""), } def write_workspace(self, user_id: str, sessions: Any, settings: Any) -> dict[str, Any]: clean_sessions=self.sanitize_workspace_sessions(sessions) clean_settings=self.sanitize_workspace_settings(settings) payload={ "schema":"squid-server-workspace", "schema_version":1, "updated_at":datetime.now().astimezone().isoformat(timespec="seconds"), "sessions":clean_sessions, "settings":clean_settings, } path=self.workspace_state_file(user_id) path.parent.mkdir(parents=True, exist_ok=True) tmp=path.with_suffix(path.suffix+".tmp") tmp.write_text(json.dumps(payload,ensure_ascii=False,indent=2),encoding="utf-8") tmp.replace(path) return payload def favorite_state_file(self, user_id: str) -> Path: return self.root / f"{self._require_user(user_id)}.favorites.json" def sanitize_favorite(self, item: Any) -> dict[str, Any] | None: if not isinstance(item, dict): return None kind=str(item.get("kind") or "answer") if kind not in {"dataset","file","answer"}: kind="answer" def cut(key: str, n: int) -> str: return str(item.get(key) or "")[:n] return { "kind":kind, "id":cut("id",240), "key":cut("key",800), "title":cut("title",400), "content":cut("content",60000), "prompt":cut("prompt",10000), "repository":cut("repository",500), "path":cut("path",2000), "created_at":int(item.get("created_at") or int(time.time()*1000)), } def read_favorites(self, user_id: str) -> list[dict[str, Any]]: path=self.favorite_state_file(user_id) if not path.exists(): return [] try: raw=json.loads(path.read_text(encoding="utf-8")) except Exception: return [] items=raw.get("favorites",[]) if isinstance(raw,dict) else raw out=[] for item in items if isinstance(items,list) else []: clean=self.sanitize_favorite(item) if clean: out.append(clean) return out[:self.max_favorites] def write_favorites(self, user_id: str, favorites: list[dict[str, Any]]) -> list[dict[str, Any]]: cleaned=[]; seen=set() for item in favorites: clean=self.sanitize_favorite(item) if not clean: continue ident=clean.get("id") or f"{clean.get('kind')}:{clean.get('key')}:{clean.get('title')}" if ident in seen: continue seen.add(ident); cleaned.append(clean) if len(cleaned)>=self.max_favorites: break path=self.favorite_state_file(user_id) path.parent.mkdir(parents=True, exist_ok=True) tmp=path.with_suffix(path.suffix+".tmp") payload={ "schema":"squid-server-favorites", "schema_version":1, "updated_at":datetime.now().astimezone().isoformat(timespec="seconds"), "favorites":cleaned, } tmp.write_text(json.dumps(payload,ensure_ascii=False,indent=2),encoding="utf-8") tmp.replace(path) return cleaned def storage_mode(self) -> str: try: return "persistent" if str(self.root.resolve()).startswith("/data/") else "server_session" except Exception: return "server_session"