# ============================================================ # 项目 / 会话服务端仓(moduleId: state-projects, AG-08 首切片,可重生 ✅) # 规则:项目/会话/消息持久化到 JSON;删除项目不触碰世界状态 # ============================================================ from __future__ import annotations import json import os import tempfile import threading import uuid from datetime import datetime from typing import Any from server.timeutil import fmt_dt PERSONAL_PROJECT_ID = "__personal__" World = dict[str, Any] def _now() -> str: return fmt_dt(datetime.now()) def _uid(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex[:8]}" def _seed() -> dict[str, Any]: sess = { "id": _uid("sess"), "projectId": PERSONAL_PROJECT_ID, "title": "新话题", "status": "running", "updatedAt": _now(), } return { "projects": [], "sessions": [sess], "files": [], "messages": {}, "activeProjectId": PERSONAL_PROJECT_ID, "activeSessionId": sess["id"], "worldKey": "default", # 与当前全局 WorldStore 绑定(单世界) } class ProjectStore: """项目制工作区:项目/会话/文件/消息(单文件 JSON + 原子写)。""" def __init__(self, path: str | None = None) -> None: self.path = path or os.environ.get("APS_PROJECTS_PATH", "server/data/projects.json") self._lock = threading.Lock() self.data: dict[str, Any] = self._load() def _load(self) -> dict[str, Any]: try: with open(self.path, "r", encoding="utf-8") as f: raw = json.load(f) if not isinstance(raw, dict) or not raw.get("sessions"): data = _seed() self._write(data) return data raw.setdefault("projects", []) raw.setdefault("files", []) raw.setdefault("messages", {}) raw.setdefault("worldKey", "default") raw.setdefault("activeProjectId", PERSONAL_PROJECT_ID) raw.setdefault("activeSessionId", raw["sessions"][0]["id"]) return raw except (FileNotFoundError, json.JSONDecodeError, TypeError): data = _seed() self._write(data) return data def _write(self, data: dict[str, Any]) -> None: os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=1) os.replace(tmp, self.path) except BaseException: if os.path.exists(tmp): os.unlink(tmp) raise def save(self) -> None: with self._lock: self._write(self.data) def snapshot(self, *, include_messages: bool = True) -> dict[str, Any]: """只读投影(P0)。""" with self._lock: return self._snapshot_unlocked(include_messages=include_messages) def _snapshot_unlocked(self, *, include_messages: bool = True) -> dict[str, Any]: out = { "projects": list(self.data.get("projects") or []), "sessions": list(self.data.get("sessions") or []), "files": list(self.data.get("files") or []), "activeProjectId": self.data.get("activeProjectId"), "activeSessionId": self.data.get("activeSessionId"), "worldKey": self.data.get("worldKey") or "default", } if include_messages: out["messages"] = dict(self.data.get("messages") or {}) return out def replace_workspace(self, payload: dict[str, Any]) -> dict[str, Any]: """整包替换工作区元数据(可含 messages);不碰 WorldStore。""" with self._lock: projects = list(payload.get("projects") or []) sessions = list(payload.get("sessions") or []) files = list(payload.get("files") or []) if not sessions: seeded = _seed() sessions = seeded["sessions"] payload = {**payload, "activeSessionId": sessions[0]["id"], "activeProjectId": PERSONAL_PROJECT_ID} messages = payload.get("messages") if messages is None: messages = self.data.get("messages") or {} # 清掉已删除会话的消息,防泄漏 keep = {s["id"] for s in sessions if isinstance(s, dict) and s.get("id")} messages = {k: v for k, v in dict(messages).items() if k in keep} self.data = { "projects": projects, "sessions": sessions, "files": files, "messages": messages, "activeProjectId": payload.get("activeProjectId") or PERSONAL_PROJECT_ID, "activeSessionId": payload.get("activeSessionId") or sessions[0]["id"], "worldKey": payload.get("worldKey") or self.data.get("worldKey") or "default", } self._write(self.data) return self._snapshot_unlocked(include_messages=True) def create_project(self, name: str, *, scope_label: str = "未设定作用域") -> dict[str, Any]: with self._lock: pid = _uid("proj") sid = _uid("sess") project = { "id": pid, "name": (name or "").strip() or "未命名项目", "scopeLabel": scope_label or "未设定作用域", "createdAt": _now(), "worldKey": self.data.get("worldKey") or "default", } session = { "id": sid, "projectId": pid, "title": "新话题", "status": "running", "updatedAt": _now(), } self.data["projects"] = [project, *(self.data.get("projects") or [])] self.data["sessions"] = [session, *(self.data.get("sessions") or [])] self.data["activeProjectId"] = pid self.data["activeSessionId"] = sid self._write(self.data) return {"project": project, "session": session} def delete_project(self, project_id: str) -> dict[str, Any]: """删除项目元数据与其会话/文件/消息;绝不修改世界状态。""" if project_id == PERSONAL_PROJECT_ID: raise ValueError("个人话题作用域不可删除") with self._lock: before = next((p for p in self.data.get("projects") or [] if p["id"] == project_id), None) if before is None: raise ValueError(f"项目不存在:{project_id}") drop_sessions = {s["id"] for s in self.data.get("sessions") or [] if s.get("projectId") == project_id} self.data["projects"] = [p for p in self.data["projects"] if p["id"] != project_id] self.data["sessions"] = [s for s in self.data["sessions"] if s["id"] not in drop_sessions] self.data["files"] = [f for f in self.data.get("files") or [] if f.get("projectId") != project_id] msgs = dict(self.data.get("messages") or {}) for sid in drop_sessions: msgs.pop(sid, None) self.data["messages"] = msgs if not self.data["sessions"]: fresh = { "id": _uid("sess"), "projectId": PERSONAL_PROJECT_ID, "title": "新话题", "status": "running", "updatedAt": _now(), } self.data["sessions"] = [fresh] if self.data.get("activeProjectId") == project_id: self.data["activeProjectId"] = ( self.data["projects"][0]["id"] if self.data["projects"] else PERSONAL_PROJECT_ID) if self.data.get("activeSessionId") in drop_sessions or not self.data.get("activeSessionId"): scope = self.data["activeProjectId"] sib = next((s for s in self.data["sessions"] if s["projectId"] == scope), None) self.data["activeSessionId"] = (sib or self.data["sessions"][0])["id"] self._write(self.data) return { "deletedProjectId": project_id, "deletedSessionIds": sorted(drop_sessions), "worldUntouched": True, "worldKey": self.data.get("worldKey") or "default", } def create_session(self, project_id: str, title: str = "新话题") -> dict[str, Any]: with self._lock: if project_id != PERSONAL_PROJECT_ID: if not any(p["id"] == project_id for p in self.data.get("projects") or []): raise ValueError(f"项目不存在:{project_id}") session = { "id": _uid("sess"), "projectId": project_id, "title": (title or "").strip() or "新话题", "status": "running", "updatedAt": _now(), } self.data["sessions"] = [session, *(self.data.get("sessions") or [])] self.data["activeProjectId"] = project_id self.data["activeSessionId"] = session["id"] self._write(self.data) return {"session": session} def replace_messages(self, session_id: str, messages: list[Any]) -> dict[str, Any]: with self._lock: if not any(s["id"] == session_id for s in self.data.get("sessions") or []): raise ValueError(f"会话不存在:{session_id}") self.data.setdefault("messages", {})[session_id] = list(messages or []) for s in self.data["sessions"]: if s["id"] == session_id: s["updatedAt"] = _now() break self._write(self.data) return {"sessionId": session_id, "count": len(messages or [])} def append_message(self, session_id: str, message: dict[str, Any]) -> dict[str, Any]: with self._lock: if not any(s["id"] == session_id for s in self.data.get("sessions") or []): raise ValueError(f"会话不存在:{session_id}") bucket = list((self.data.get("messages") or {}).get(session_id) or []) bucket.append(message) self.data.setdefault("messages", {})[session_id] = bucket for s in self.data["sessions"]: if s["id"] == session_id: s["updatedAt"] = _now() break self._write(self.data) return {"sessionId": session_id, "count": len(bucket)} def get_messages(self, session_id: str) -> list[Any]: with self._lock: return list((self.data.get("messages") or {}).get(session_id) or []) _store: ProjectStore | None = None def get_project_store() -> ProjectStore: global _store if _store is None: _store = ProjectStore() return _store