fix(webui): sort sessions by transcript activity

This commit is contained in:
chengyongru
2026-06-15 17:44:26 +08:00
parent cbb4c0bad2
commit 167a53dc45
3 changed files with 158 additions and 7 deletions
+70 -4
View File
@@ -9,11 +9,13 @@ from __future__ import annotations
import json import json
import os import os
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from loguru import logger from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.manager import ( from nanobot.session.manager import (
_SESSION_LIST_PREVIEW_MAX_CHARS, _SESSION_LIST_PREVIEW_MAX_CHARS,
@@ -26,6 +28,8 @@ from nanobot.session.manager import (
_INDEX_VERSION = 1 _INDEX_VERSION = 1
_INDEX_FILENAME = ".webui_session_index.json" _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]]: 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) signature = _file_signature(path)
except OSError: except OSError:
return False 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]: 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 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]: def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
signature = _file_signature(path) signature = _file_signature(path)
activity_signature = _webui_activity_signature(session.key)
activity_updated_at = _webui_activity_updated_at(activity_signature)
return { return {
"key": session.key, "key": session.key,
"created_at": session.created_at.isoformat(), "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), "title": _metadata_title(session.metadata),
"preview": _preview_from_messages(session.messages), "preview": _preview_from_messages(session.messages),
"file": path.name, "file": path.name,
"mtime_ns": signature["mtime_ns"], "mtime_ns": signature["mtime_ns"],
"size": signature["size"], "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": if not fallback_preview and item.get("role") == "assistant":
fallback_preview = text fallback_preview = text
signature = _file_signature(path) 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 { return {
"key": data.get("key") or fallback_key, "key": key,
"created_at": data.get("created_at"), "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", {})), "title": _metadata_title(data.get("metadata", {})),
"preview": preview or fallback_preview, "preview": preview or fallback_preview,
"file": path.name, "file": path.name,
"mtime_ns": signature["mtime_ns"], "mtime_ns": signature["mtime_ns"],
"size": signature["size"], "size": signature["size"],
**activity_signature,
} }
except Exception: except Exception:
repaired = session_manager._repair(fallback_key) repaired = session_manager._repair(fallback_key)
+83
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os
from datetime import datetime
from pathlib import Path from pathlib import Path
import nanobot.webui.session_list_index as session_list_index 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"] == "提醒已经到期。" 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]: def list_webui_sessions(manager: SessionManager) -> list[dict]:
return session_list_index.list_webui_sessions(manager) return session_list_index.list_webui_sessions(manager)
@@ -1733,9 +1733,11 @@ export function SettingsView({
{t("settings.backToChat")} {t("settings.backToChat")}
</button> </button>
) : null} ) : null}
<p className="mb-2 text-[12px] font-normal text-muted-foreground"> {activeSection !== "automations" ? (
{t("settings.sidebar.title")} <p className="mb-2 text-[12px] font-normal text-muted-foreground">
</p> {t("settings.sidebar.title")}
</p>
) : null}
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]"> <h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))} {text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
</h1> </h1>