ASE-GLM / server /chats.py
Bit-Trading-Company's picture
CI deploy d9f0896e
6b70fb5 verified
Raw History Blame Contribute Delete
5.31 kB
"""The shared chat store, and the ceiling it stops at.
Ephemeral is the default and needs nothing from here: those chats live in the
viewer's browser. This is the other option -- a Bit Trading Company database a
signed-in viewer's chats can follow them across devices.
Two rules shape the whole module:
* **A chat is scoped to whoever wrote it.** No owner, no store: an unauthenticated
viewer would otherwise be writing into a bucket everyone else can read.
* **Full is a normal state, not an error.** Past the ceiling the store simply
stops accepting writes and the client keeps everything on the device. There is
a real budget behind this, and silently growing past it is how a demo becomes
an invoice.
Enabled by ``ASE_CHAT_DB`` (a directory). Unset -- which is the case on the
Space today -- and every endpoint reports the store as unavailable.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
# A few gigabytes. Raise it if the data turns out to be worth the space; the
# client needs no change, it reads the ceiling out of /api/config.
DEFAULT_MAX_BYTES = 3 * 1024**3
# One conversation with a hundred turns and a dozen canvases is well under this.
MAX_CHAT_BYTES = 512 * 1024
_SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
@dataclass
class StoreState:
remote: bool
max_bytes: int
used_bytes: int
full: bool
detail: str = ""
def to_json(self) -> dict:
return {
"remote": self.remote,
"maxBytes": self.max_bytes,
"usedBytes": self.used_bytes,
"full": self.full,
"detail": self.detail,
}
def root() -> Path | None:
"""Where chats are kept, or None when no store is configured."""
configured = os.environ.get("ASE_CHAT_DB")
return Path(configured) if configured else None
def max_bytes() -> int:
try:
return max(0, int(os.environ.get("ASE_CHAT_DB_MAX_BYTES", DEFAULT_MAX_BYTES)))
except ValueError:
return DEFAULT_MAX_BYTES
def used_bytes(base: Path) -> int:
total = 0
for path in base.rglob("*.json"):
try:
total += path.stat().st_size
except OSError:
continue
return total
def state(owner: str | None) -> StoreState:
"""What the client should be told about the store, for this viewer."""
base = root()
if base is None:
return StoreState(False, 0, 0, False, "No shared store is configured on this server.")
if not owner:
return StoreState(False, max_bytes(), 0, False,
"Sign in with Hugging Face to use the shared store.")
try:
base.mkdir(parents=True, exist_ok=True)
used = used_bytes(base)
except OSError as e:
return StoreState(False, max_bytes(), 0, False, f"The store is unreachable: {e}")
cap = max_bytes()
full = used >= cap
return StoreState(True, cap, used, full,
"Full — new chats stay on the device." if full else "")
def _dir(base: Path, owner: str) -> Path:
# The owner is a Hugging Face username, but it arrives from a token rather
# than from our own validation, so it is hashed into a safe segment instead
# of being trusted as a path component.
safe = re.sub(r"[^A-Za-z0-9_-]", "_", owner)[:48]
return base / safe
def load_all(owner: str) -> list[dict]:
base = root()
if base is None or not owner:
return []
folder = _dir(base, owner)
if not folder.is_dir():
return []
out = []
for path in sorted(folder.glob("*.json")):
try:
out.append(json.loads(path.read_text("utf-8")))
except (OSError, json.JSONDecodeError):
# One unreadable chat must not take the whole history down with it.
continue
return out
def save(owner: str, chat_id: str, chat: dict) -> StoreState:
"""Write one chat. A refusal is reported as state, never as an exception."""
st = state(owner)
if not st.remote or st.full or not _SAFE_ID.match(chat_id):
return st
if chat.get("ephemeral"):
# An ephemeral chat is never written, whatever the request says. The
# client enforces this too; doing it in both places is deliberate.
return st
body = json.dumps(chat, ensure_ascii=False)
if len(body.encode("utf-8")) > MAX_CHAT_BYTES:
st.detail = "That chat is too large for the shared store."
return st
base = root()
assert base is not None
folder = _dir(base, owner)
try:
folder.mkdir(parents=True, exist_ok=True)
# Written beside the target and moved into place, so a crash mid-write
# leaves the previous version rather than half a file.
tmp = folder / f".{chat_id}.tmp"
tmp.write_text(body, "utf-8")
tmp.replace(folder / f"{chat_id}.json")
except OSError as e:
st.detail = f"Could not write: {e}"
return st
return state(owner)
def delete(owner: str, chat_id: str) -> StoreState:
base = root()
if base is not None and owner and _SAFE_ID.match(chat_id):
try:
(_dir(base, owner) / f"{chat_id}.json").unlink(missing_ok=True)
except OSError:
pass
return state(owner)