feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
317
session_store.py
Normal file
317
session_store.py
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from card_store import NANOBOT_WORKSPACE
|
||||
|
||||
NANOBOT_RUNTIME_WORKSPACE = Path(
|
||||
os.getenv(
|
||||
"NANOBOT_RUNTIME_WORKSPACE",
|
||||
str(
|
||||
(NANOBOT_WORKSPACE / "workspace")
|
||||
if (NANOBOT_WORKSPACE / "workspace").exists()
|
||||
else NANOBOT_WORKSPACE
|
||||
),
|
||||
)
|
||||
).expanduser()
|
||||
SESSIONS_DIR = NANOBOT_RUNTIME_WORKSPACE / "sessions"
|
||||
_SESSION_CHAT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
|
||||
|
||||
|
||||
def ensure_session_store_dirs() -> None:
|
||||
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _normalize_session_chat_id(raw: str) -> str:
|
||||
chat_id = raw.strip()
|
||||
return chat_id if _SESSION_CHAT_ID_PATTERN.fullmatch(chat_id) else ""
|
||||
|
||||
|
||||
def _is_web_session_chat_id(chat_id: str) -> bool:
|
||||
return chat_id == "web" or chat_id.startswith("web-")
|
||||
|
||||
|
||||
def _session_key(chat_id: str) -> str:
|
||||
return f"api:{chat_id}"
|
||||
|
||||
|
||||
def _session_path(chat_id: str) -> Path:
|
||||
return SESSIONS_DIR / f"api_{chat_id}.jsonl"
|
||||
|
||||
|
||||
def _session_summary_from_messages(
|
||||
chat_id: str,
|
||||
*,
|
||||
created_at: str,
|
||||
updated_at: str,
|
||||
metadata: dict[str, Any],
|
||||
messages: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
title = str(metadata.get("title", "")).strip()
|
||||
preview = ""
|
||||
|
||||
for message in messages:
|
||||
if message.get("role") == "user":
|
||||
text = str(message.get("content", "")).strip()
|
||||
if text:
|
||||
if not title:
|
||||
title = text
|
||||
break
|
||||
|
||||
for message in reversed(messages):
|
||||
role = str(message.get("role", "")).strip()
|
||||
text = str(message.get("content", "")).strip()
|
||||
if role in {"user", "assistant", "system"} and text:
|
||||
preview = text
|
||||
break
|
||||
|
||||
if not title:
|
||||
title = "New conversation"
|
||||
|
||||
return {
|
||||
"chat_id": chat_id,
|
||||
"key": _session_key(chat_id),
|
||||
"title": title[:120],
|
||||
"preview": preview[:240],
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
"message_count": len(messages),
|
||||
}
|
||||
|
||||
|
||||
def _load_session_file(chat_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
|
||||
ensure_session_store_dirs()
|
||||
path = _session_path(chat_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"key": _session_key(chat_id),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"metadata": {},
|
||||
}
|
||||
messages: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if payload.get("_type") == "metadata":
|
||||
metadata = payload
|
||||
continue
|
||||
messages.append(payload)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
return metadata, messages
|
||||
|
||||
|
||||
def _serialize_session_messages(messages: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
role = str(message.get("role", "")).strip().lower()
|
||||
content = str(message.get("content", "")).strip()
|
||||
timestamp = str(message.get("timestamp", "")).strip()
|
||||
if role not in {"user", "assistant", "tool", "system"}:
|
||||
continue
|
||||
if not content:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"role": "nanobot" if role == "assistant" else role,
|
||||
"text": content,
|
||||
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _load_session_summary(chat_id: str) -> dict[str, Any] | None:
|
||||
loaded = _load_session_file(chat_id)
|
||||
if loaded is None:
|
||||
return None
|
||||
metadata, messages = loaded
|
||||
meta_dict = metadata.get("metadata", {})
|
||||
return _session_summary_from_messages(
|
||||
chat_id,
|
||||
created_at=str(metadata.get("created_at", "")),
|
||||
updated_at=str(metadata.get("updated_at", "")),
|
||||
metadata=meta_dict if isinstance(meta_dict, dict) else {},
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
def _empty_web_session_payload() -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
return {
|
||||
"session": {
|
||||
"chat_id": "web",
|
||||
"key": _session_key("web"),
|
||||
"title": "Current conversation",
|
||||
"preview": "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"message_count": 0,
|
||||
},
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
|
||||
def _load_session_payload(chat_id: str) -> dict[str, Any] | None:
|
||||
loaded = _load_session_file(chat_id)
|
||||
if loaded is None:
|
||||
return _empty_web_session_payload() if chat_id == "web" else None
|
||||
|
||||
metadata, messages = loaded
|
||||
meta_dict = metadata.get("metadata", {})
|
||||
summary = _session_summary_from_messages(
|
||||
chat_id,
|
||||
created_at=str(metadata.get("created_at", "")),
|
||||
updated_at=str(metadata.get("updated_at", "")),
|
||||
metadata=meta_dict if isinstance(meta_dict, dict) else {},
|
||||
messages=messages,
|
||||
)
|
||||
return {"session": summary, "messages": _serialize_session_messages(messages)}
|
||||
|
||||
|
||||
def _list_web_sessions() -> list[dict[str, Any]]:
|
||||
ensure_session_store_dirs()
|
||||
sessions: list[dict[str, Any]] = []
|
||||
seen_chat_ids: set[str] = set()
|
||||
|
||||
for path in sorted(SESSIONS_DIR.glob("api_*.jsonl")):
|
||||
chat_id = path.stem[4:]
|
||||
if not _is_web_session_chat_id(chat_id):
|
||||
continue
|
||||
summary = _load_session_summary(chat_id)
|
||||
if summary is None:
|
||||
continue
|
||||
sessions.append(summary)
|
||||
seen_chat_ids.add(chat_id)
|
||||
|
||||
if "web" not in seen_chat_ids:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
sessions.append(
|
||||
{
|
||||
"chat_id": "web",
|
||||
"key": _session_key("web"),
|
||||
"title": "Current conversation",
|
||||
"preview": "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"message_count": 0,
|
||||
}
|
||||
)
|
||||
|
||||
sessions.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
|
||||
return sessions
|
||||
|
||||
|
||||
def _create_session(chat_id: str, title: str = "") -> dict[str, Any]:
|
||||
ensure_session_store_dirs()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
payload = {
|
||||
"_type": "metadata",
|
||||
"key": _session_key(chat_id),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"metadata": {"title": title.strip()} if title.strip() else {},
|
||||
"last_consolidated": 0,
|
||||
}
|
||||
_session_path(chat_id).write_text(
|
||||
json.dumps(payload, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
summary = _load_session_summary(chat_id)
|
||||
if summary is None:
|
||||
raise RuntimeError("failed to create session")
|
||||
return summary
|
||||
|
||||
|
||||
def _write_session_file(
|
||||
chat_id: str, metadata: dict[str, Any], messages: list[dict[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
try:
|
||||
ensure_session_store_dirs()
|
||||
encoded_lines = [json.dumps(metadata, ensure_ascii=False)]
|
||||
encoded_lines.extend(json.dumps(message, ensure_ascii=False) for message in messages)
|
||||
_session_path(chat_id).write_text("\n".join(encoded_lines) + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
return _load_session_summary(chat_id)
|
||||
|
||||
|
||||
def _rename_session(chat_id: str, title: str) -> dict[str, Any] | None:
|
||||
loaded = _load_session_file(chat_id)
|
||||
if loaded is None:
|
||||
if chat_id != "web":
|
||||
return None
|
||||
return _create_session(chat_id, title=title)
|
||||
|
||||
metadata, messages = loaded
|
||||
meta_dict = metadata.get("metadata", {})
|
||||
if not isinstance(meta_dict, dict):
|
||||
meta_dict = {}
|
||||
if title.strip():
|
||||
meta_dict["title"] = title.strip()
|
||||
else:
|
||||
meta_dict.pop("title", None)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
normalized_metadata = {
|
||||
**metadata,
|
||||
"_type": "metadata",
|
||||
"key": _session_key(chat_id),
|
||||
"created_at": str(metadata.get("created_at", "") or now),
|
||||
"updated_at": now,
|
||||
"metadata": meta_dict,
|
||||
"last_consolidated": metadata.get("last_consolidated", 0),
|
||||
}
|
||||
return _write_session_file(chat_id, normalized_metadata, messages)
|
||||
|
||||
|
||||
def _delete_session(chat_id: str, delete_cards_for_chat: Callable[[str], int]) -> bool:
|
||||
path = _session_path(chat_id)
|
||||
deleted_file = False
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
deleted_file = True
|
||||
except OSError:
|
||||
return False
|
||||
deleted_cards = delete_cards_for_chat(chat_id)
|
||||
return deleted_file or deleted_cards > 0 or chat_id == "web"
|
||||
|
||||
|
||||
normalize_session_chat_id = _normalize_session_chat_id
|
||||
is_web_session_chat_id = _is_web_session_chat_id
|
||||
list_web_sessions = _list_web_sessions
|
||||
load_session_payload = _load_session_payload
|
||||
create_session = _create_session
|
||||
rename_session = _rename_session
|
||||
delete_session = _delete_session
|
||||
|
||||
__all__ = [
|
||||
"SESSIONS_DIR",
|
||||
"create_session",
|
||||
"delete_session",
|
||||
"ensure_session_store_dirs",
|
||||
"is_web_session_chat_id",
|
||||
"list_web_sessions",
|
||||
"load_session_payload",
|
||||
"normalize_session_chat_id",
|
||||
"rename_session",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue