diff --git a/nanobot/webui/session_list_index.py b/nanobot/webui/session_list_index.py index 4fba2ca1..aaadc97a 100644 --- a/nanobot/webui/session_list_index.py +++ b/nanobot/webui/session_list_index.py @@ -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) diff --git a/tests/webui/test_session_list_index.py b/tests/webui/test_session_list_index.py index c8b0adf6..cc10f8c1 100644 --- a/tests/webui/test_session_list_index.py +++ b/tests/webui/test_session_list_index.py @@ -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) diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 04b8e5aa..5fe36e4c 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1733,9 +1733,11 @@ export function SettingsView({ {t("settings.backToChat")} ) : null} -
- {t("settings.sidebar.title")} -
+ {activeSection !== "automations" ? ( ++ {t("settings.sidebar.title")} +
+ ) : null}