fix(webui): sort sessions by transcript activity
This commit is contained in:
@@ -9,11 +9,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_webui_dir
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.session.manager import (
|
||||
_SESSION_LIST_PREVIEW_MAX_CHARS,
|
||||
@@ -26,6 +28,8 @@ from nanobot.session.manager import (
|
||||
|
||||
_INDEX_VERSION = 1
|
||||
_INDEX_FILENAME = ".webui_session_index.json"
|
||||
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
||||
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
||||
|
||||
|
||||
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
||||
@@ -117,7 +121,13 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path) -> bool:
|
||||
signature = _file_signature(path)
|
||||
except OSError:
|
||||
return False
|
||||
return row.get("mtime_ns") == signature["mtime_ns"] and row.get("size") == signature["size"]
|
||||
activity_signature = _webui_activity_signature(str(row.get("key")))
|
||||
return (
|
||||
row.get("mtime_ns") == signature["mtime_ns"]
|
||||
and row.get("size") == signature["size"]
|
||||
and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS]
|
||||
and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE]
|
||||
)
|
||||
|
||||
|
||||
def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -155,17 +165,69 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
||||
return fallback_preview
|
||||
|
||||
|
||||
def _webui_activity_paths(session_key: str) -> list[Path]:
|
||||
stem = SessionManager.safe_key(session_key)
|
||||
webui_dir = get_webui_dir()
|
||||
return [
|
||||
webui_dir / f"{stem}.jsonl",
|
||||
webui_dir / f"{stem}.json",
|
||||
]
|
||||
|
||||
|
||||
def _webui_activity_signature(session_key: str) -> dict[str, int]:
|
||||
latest_mtime_ns = 0
|
||||
total_size = 0
|
||||
for path in _webui_activity_paths(session_key):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if not path.is_file():
|
||||
continue
|
||||
latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
|
||||
total_size += stat.st_size
|
||||
return {
|
||||
_WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
|
||||
_WEBUI_ACTIVITY_SIZE: total_size,
|
||||
}
|
||||
|
||||
|
||||
def _webui_activity_updated_at(signature: dict[str, int]) -> str | None:
|
||||
mtime_ns = signature.get(_WEBUI_ACTIVITY_MTIME_NS, 0)
|
||||
if mtime_ns <= 0:
|
||||
return None
|
||||
return datetime.fromtimestamp(mtime_ns / 1_000_000_000).isoformat()
|
||||
|
||||
|
||||
def _timestamp(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
return datetime.fromisoformat(value).timestamp()
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
|
||||
if _timestamp(activity) > _timestamp(stored):
|
||||
return activity
|
||||
return stored
|
||||
|
||||
|
||||
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
||||
signature = _file_signature(path)
|
||||
activity_signature = _webui_activity_signature(session.key)
|
||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||
return {
|
||||
"key": session.key,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": session.updated_at.isoformat(),
|
||||
"updated_at": _latest_updated_at(session.updated_at.isoformat(), activity_updated_at),
|
||||
"title": _metadata_title(session.metadata),
|
||||
"preview": _preview_from_messages(session.messages),
|
||||
"file": path.name,
|
||||
"mtime_ns": signature["mtime_ns"],
|
||||
"size": signature["size"],
|
||||
**activity_signature,
|
||||
}
|
||||
|
||||
|
||||
@@ -207,15 +269,19 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
||||
if not fallback_preview and item.get("role") == "assistant":
|
||||
fallback_preview = text
|
||||
signature = _file_signature(path)
|
||||
key = data.get("key") or fallback_key
|
||||
activity_signature = _webui_activity_signature(key)
|
||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||
return {
|
||||
"key": data.get("key") or fallback_key,
|
||||
"key": key,
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"updated_at": _latest_updated_at(data.get("updated_at"), activity_updated_at),
|
||||
"title": _metadata_title(data.get("metadata", {})),
|
||||
"preview": preview or fallback_preview,
|
||||
"file": path.name,
|
||||
"mtime_ns": signature["mtime_ns"],
|
||||
"size": signature["size"],
|
||||
**activity_signature,
|
||||
}
|
||||
except Exception:
|
||||
repaired = session_manager._repair(fallback_key)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import nanobot.webui.session_list_index as session_list_index
|
||||
@@ -86,5 +88,86 @@ def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) ->
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。"
|
||||
|
||||
|
||||
def test_webui_session_list_uses_webui_transcript_activity_for_sort(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
webui_dir = tmp_path / "webui"
|
||||
webui_dir.mkdir()
|
||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||
|
||||
manager = SessionManager(tmp_path)
|
||||
old_session = manager.get_or_create("websocket:old-metadata")
|
||||
old_session.created_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
old_session.add_message("user", "old metadata")
|
||||
old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
manager.save(old_session)
|
||||
|
||||
newer_metadata = manager.get_or_create("websocket:newer-metadata")
|
||||
newer_metadata.created_at = datetime(2026, 6, 15, 11, 0, 0)
|
||||
newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0)
|
||||
newer_metadata.add_message("user", "newer metadata")
|
||||
newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0)
|
||||
manager.save(newer_metadata)
|
||||
|
||||
transcript = webui_dir / "websocket_old-metadata.jsonl"
|
||||
transcript.write_text(
|
||||
'{"event":"turn_end","chat_id":"old-metadata"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
activity_ns = int(datetime(2026, 6, 15, 12, 0, 0).timestamp() * 1_000_000_000)
|
||||
os.utime(transcript, ns=(activity_ns, activity_ns))
|
||||
|
||||
rows = list_webui_sessions(manager)
|
||||
|
||||
assert [row["key"] for row in rows] == [
|
||||
"websocket:old-metadata",
|
||||
"websocket:newer-metadata",
|
||||
]
|
||||
assert rows[0]["updated_at"].startswith("2026-06-15T12:00:00")
|
||||
|
||||
|
||||
def test_webui_session_list_rescans_when_transcript_changes(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
webui_dir = tmp_path / "webui"
|
||||
webui_dir.mkdir()
|
||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:transcript-change")
|
||||
session.created_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
session.add_message("user", "preview")
|
||||
session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
manager.save(session)
|
||||
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "preview"
|
||||
|
||||
transcript = webui_dir / "websocket_transcript-change.jsonl"
|
||||
transcript.write_text(
|
||||
'{"event":"turn_end","chat_id":"transcript-change"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
activity_ns = int(datetime(2026, 6, 15, 12, 30, 0).timestamp() * 1_000_000_000)
|
||||
os.utime(transcript, ns=(activity_ns, activity_ns))
|
||||
|
||||
original_scan = session_list_index._scan_session_row
|
||||
scanned: list[str] = []
|
||||
|
||||
def record_scan(session_manager: SessionManager, path: Path) -> dict | None:
|
||||
scanned.append(path.name)
|
||||
return original_scan(session_manager, path)
|
||||
|
||||
monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan)
|
||||
|
||||
rows = list_webui_sessions(manager)
|
||||
|
||||
assert scanned == [manager._get_session_path("websocket:transcript-change").name]
|
||||
assert rows[0]["updated_at"].startswith("2026-06-15T12:30:00")
|
||||
|
||||
|
||||
def list_webui_sessions(manager: SessionManager) -> list[dict]:
|
||||
return session_list_index.list_webui_sessions(manager)
|
||||
|
||||
@@ -1733,9 +1733,11 @@ export function SettingsView({
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
) : null}
|
||||
<p className="mb-2 text-[12px] font-normal text-muted-foreground">
|
||||
{t("settings.sidebar.title")}
|
||||
</p>
|
||||
{activeSection !== "automations" ? (
|
||||
<p className="mb-2 text-[12px] font-normal text-muted-foreground">
|
||||
{t("settings.sidebar.title")}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
|
||||
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
|
||||
</h1>
|
||||
|
||||
Reference in New Issue
Block a user