Files
ai-agent/backend/ui_state.py
Gabriel Vidal 840bde9030 feat(ai-agent): persist Settings + notification state in a file, not localStorage
Move the two Zustand `persist` stores off browser localStorage and onto a
server-side file (`/data/ui-state.json`) so the config follows the user across
browsers and devices instead of being trapped per-browser.

- backend: `ui_state.py` — a dumb key→string JSON-file store (key = persist
  store name, value = its opaque serialized blob), exposed via
  `GET/PUT/DELETE /api/ui-state/{key}` (atomic temp-file rewrite, key validated).
- frontend: `lib/serverStorage.ts` — a `StateStorage` backend that reads/writes
  those endpoints; failures are swallowed so a network blip can't white-screen
  the UI. Includes a one-time self-cleaning migration that seeds the server file
  from any leftover localStorage value then clears it.
- point `useSettings` (ai-agent-settings) and the main store (ai-agent-store —
  notifKnown/notifNew/notifSeeded + drafts/sidebar) at serverStorage.
- hydration is now async: gate `syncNotifs` on `useStore.persist.hasHydrated()`
  (new `lib/useHydrated`) so the notification baseline isn't seeded from empty
  defaults before the server value lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:15:47 +02:00

68 lines
2.4 KiB
Python

"""
Server-side persistence for the PWA's small client state — the Settings panel
config (theme, currency, subscription, prompt shortcuts) and the notification
feed's seen/new bookkeeping — that used to live in the browser's ``localStorage``.
Moving it into a file (``/data/ui-state.json``) makes the config follow the user
across browsers and devices instead of being trapped per-browser, and survives a
localStorage clear. The store is a dumb key→string map: each key is a Zustand
``persist`` store name (``ai-agent-settings`` / ``ai-agent-store``) and the value
is that store's already-serialized JSON blob — the backend never inspects it, it
just holds the opaque string the frontend hands it.
Single JSON file, whole-map rewrite on every set (atomic via a temp file + rename),
mirroring ``meta.py``. The map is tiny, so this is cheap.
"""
import json
import os
import pathlib
import re
import threading
# Keys are Zustand persist store names — restrict to a safe, fixed shape so a
# bogus key can never escape the single JSON blob or collide with anything else.
_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
def valid_key(key: str) -> bool:
return bool(_KEY_RE.match(key))
class UiStateStore:
def __init__(self, path: str):
self.path = pathlib.Path(path)
self._lock = threading.Lock()
self._data: dict[str, str] = {}
self._load()
def _load(self) -> None:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
# Keep only string values; drop anything unexpected on the floor.
self._data = {k: v for k, v in data.items() if isinstance(v, str)}
except (OSError, ValueError):
self._data = {}
def get(self, key: str) -> str | None:
with self._lock:
return self._data.get(key)
def set(self, key: str, value: str) -> None:
with self._lock:
self._data[key] = value
self._save()
def delete(self, key: str) -> None:
with self._lock:
if key in self._data:
del self._data[key]
self._save()
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(self._data, indent=2, sort_keys=True),
encoding="utf-8")
os.replace(tmp, self.path)