257 lines
11 KiB
Python
257 lines
11 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 项目 / 会话服务端仓(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
|