diff --git a/AGENTS.md b/AGENTS.md index 6c0036efd..d6b600715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,8 +49,8 @@ hermes-agent/ │ ├── hermes-achievements/ # Gamified achievement tracking │ ├── observability/ # Metrics / traces / logs plugin │ ├── image_gen/ # Image-generation providers -│ └── / # disk-cleanup, example-dashboard, google_meet, platforms, -│ # spotify, strike-freedom-cockpit, ... +│ └── / # disk-cleanup, google_meet, platforms, spotify, +│ # strike-freedom-cockpit, ... ├── optional-skills/ # Heavier/niche skills shipped but NOT active by default ├── skills/ # Built-in skills bundled with the repo ├── ui-tui/ # Ink (React) terminal UI — `hermes --tui` diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4a3e20a6e..4a8e5d45e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3925,6 +3925,117 @@ def _session_latest_descendant(session_id: str): finally: db.close() + +# CRITICAL — every literal-path route below MUST be declared BEFORE the +# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/ +# Starlette match routes in registration order, and the ``{session_id}`` +# pattern is unconstrained — it would otherwise swallow e.g. +# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or +# ``GET /api/sessions/stats`` as "operate on the session with id +# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse, +# succeed and delete the wrong row). Same story as the older +# ``/api/sessions/search`` endpoint up at line ~1191. If you split or +# reorder this block, move every route in it together. +class BulkDeleteSessions(BaseModel): + ids: List[str] + + +@app.post("/api/sessions/bulk-delete") +async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): + """Delete every session in ``body.ids`` in a single DB transaction. + + Backs the dashboard's bulk-select-and-delete flow on the sessions + page. POST (not DELETE) because most HTTP clients refuse to send a + request body on DELETE and a body is the natural shape for a list + of IDs — Starlette accepts both, but POSTing a list keeps proxies, + curl, and the browser ``fetch`` API consistent. + + Per-row contract matches :meth:`SessionDB.delete_sessions`: + + * Unknown IDs are silently skipped (the response ``deleted`` count + reflects what really happened, not the input length). This is + deliberate — UI selection state can race against another tab's + delete, and we'd rather succeed-on-the-rest than fail-the-whole- + batch. + * Children of every deleted parent are orphaned, not cascade- + deleted. + * Active and archived sessions ARE deleted when explicitly + selected — unlike ``DELETE /api/sessions/empty``, the user + hand-picked the rows so we trust the selection. + * Like the other session-delete endpoints, this does NOT pass a + ``sessions_dir`` through; on-disk transcript / request-dump + cleanup runs at the CLI/agent layer on the next prune pass. + + The response carries the actual deleted count, so the dashboard + can surface it in a toast. The IDs that were removed are not + echoed back because the client already knows what it asked to + delete (unknown IDs are silently skipped — see contract above) + and can prune its in-memory list directly from the request. + """ + # Enforce a hard cap so a runaway/typo'd selection can't lock the + # DB writer for an extended window. The dashboard pages 20 rows + # at a time; 500 covers a "select all on every page in a + # reasonable scrollback" worst case without opening the door to + # multi-thousand-row transactions. + if len(body.ids) > 500: + raise HTTPException( + status_code=400, + detail="ids must contain at most 500 entries", + ) + from hermes_state import SessionDB + db = SessionDB() + try: + deleted = db.delete_sessions(body.ids) + return {"ok": True, "deleted": deleted} + finally: + db.close() + + +@app.get("/api/sessions/empty/count") +async def count_empty_sessions_endpoint(): + """Return the number of empty, ended, non-archived sessions. + + Drives the dashboard's "Delete empty (N)" button — when N is 0 the + UI hides the affordance so users aren't presented with a button + that does nothing. Cheap, single-COUNT query. + """ + from hermes_state import SessionDB + db = SessionDB() + try: + return {"count": db.count_empty_sessions()} + finally: + db.close() + + +@app.delete("/api/sessions/empty") +async def delete_empty_sessions_endpoint(): + """Delete every empty (``message_count == 0``), ended, + non-archived session in a single transaction. + + Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`: + + * Active sessions are skipped (``ended_at IS NULL``) so a live + agent isn't yanked mid-handshake. + * Archived sessions are skipped — the user explicitly chose to + keep those rows. + * Children of deleted parents are orphaned, not cascade-deleted. + + Like the single-session ``DELETE /api/sessions/{id}`` endpoint + below, this doesn't pass a ``sessions_dir`` through — the on-disk + transcript / request-dump cleanup is wired at the CLI/agent layer + but the web server historically leaves file cleanup to the next + prune-on-startup pass. Matching that pre-existing trade-off keeps + the two delete endpoints' DB-vs-disk behaviour consistent. + """ + from hermes_state import SessionDB + db = SessionDB() + try: + deleted = db.delete_empty_sessions() + return {"ok": True, "deleted": deleted} + finally: + db.close() + + @app.get("/api/sessions/stats") async def get_session_stats(): """Session-store statistics for the Sessions page (mirrors `hermes sessions stats`). @@ -3957,6 +4068,7 @@ async def get_session_stats(): finally: db.close() + @app.get("/api/sessions/{session_id}") async def get_session_detail(session_id: str): from hermes_state import SessionDB @@ -6745,6 +6857,7 @@ def mount_spa(application: FastAPI): _BUILTIN_DASHBOARD_THEMES = [ {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, + {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"}, {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, diff --git a/hermes_state.py b/hermes_state.py index 6ee15ebd0..268086b6b 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3186,6 +3186,178 @@ class SessionDB: self._remove_session_files(sessions_dir, session_id) return deleted + def delete_sessions( + self, + session_ids: List[str], + sessions_dir: Optional[Path] = None, + ) -> int: + """Delete every session in *session_ids* in a single transaction. + + Backs the dashboard's bulk-select-then-delete flow on the + sessions page (``POST /api/sessions/bulk-delete``). Mirrors the + single-session :meth:`delete_session` contract per row: + + * Unknown IDs are silently skipped (no 404) — selection state + in the UI can race against another tab's delete, and we'd + rather succeed-on-the-rest than fail-the-whole-batch. + * Children of every deleted ID are orphaned + (``parent_session_id → NULL``), never cascade-deleted, so a + branch / subagent transcript survives an inadvertent parent + delete. + * Messages and the session row both go in one + ``_execute_write`` call so a partial failure can't leave the + DB in a "messages gone but session row still there" state. + * On-disk transcript / ``request_dump_*`` files are cleaned up + outside the DB transaction when *sessions_dir* is provided, + matching :meth:`prune_sessions` and + :meth:`delete_empty_sessions`. + + Returns the count of sessions that actually existed and were + deleted (may be less than ``len(session_ids)`` if some IDs were + already gone). + """ + if not session_ids: + return 0 + # Dedup + drop any non-string entries up-front. Avoids + # double-counting in the WHERE-IN list and protects against + # callers that pass a list with stray ``None`` values. + unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid}) + if not unique_ids: + return 0 + + removed_ids: list[str] = [] + + def _do(conn): + placeholders = ",".join("?" * len(unique_ids)) + # First, filter to IDs that actually exist — we want to + # return the real deleted count, not the input length. + cursor = conn.execute( + f"SELECT id FROM sessions WHERE id IN ({placeholders})", + unique_ids, + ) + existing = [row["id"] for row in cursor.fetchall()] + if not existing: + return 0 + + existing_placeholders = ",".join("?" * len(existing)) + # Orphan children whose parent is in the kill list so the + # FK constraint stays satisfied. Pin children whose parent + # is itself in the kill list rather than NULL-ing parents + # of survivors — the IN list on ``parent_session_id`` does + # exactly this. + conn.execute( + f"UPDATE sessions SET parent_session_id = NULL " + f"WHERE parent_session_id IN ({existing_placeholders})", + existing, + ) + conn.execute( + f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})", + existing, + ) + conn.execute( + f"DELETE FROM sessions WHERE id IN ({existing_placeholders})", + existing, + ) + removed_ids.extend(existing) + return len(existing) + + count = self._execute_write(_do) + for sid in removed_ids: + self._remove_session_files(sessions_dir, sid) + return count + + def count_empty_sessions(self) -> int: + """Return the count of empty, non-active, non-archived sessions. + + "Empty" = ``message_count = 0`` AND the session has ended + (``ended_at IS NOT NULL``) AND is not archived. The ``ended_at`` + guard matches the safety contract used by :meth:`prune_sessions`: + only ended sessions are candidates for bulk deletion, so a freshly + spawned session whose first message hasn't landed yet — or one + held open by the live agent — is never sniped out from under + the runtime. + + Backs the ``GET /api/sessions/empty/count`` endpoint that lets the + web dashboard hide its "Delete empty" button when there's nothing + to clean up, and pre-populate the confirm dialog with the actual + count. + """ + with self._lock: + cursor = self._conn.execute( + "SELECT COUNT(*) FROM sessions " + "WHERE message_count = 0 " + "AND ended_at IS NOT NULL " + "AND archived = 0" + ) + return cursor.fetchone()[0] + + def delete_empty_sessions( + self, + sessions_dir: Optional[Path] = None, + ) -> int: + """Delete every empty, ended, non-archived session. + + Mirrors :meth:`prune_sessions`' transactional shape: + + * Selects candidate IDs first (``message_count = 0`` AND + ``ended_at IS NOT NULL`` AND ``archived = 0``) so we never + touch a live session or one the user deliberately archived. + * Orphans any child whose parent is in the kill list — children + of an empty parent are kept and re-parented to ``NULL`` rather + than cascade-deleted, matching ``delete_session`` / + ``prune_sessions`` semantics so branch/subagent transcripts + survive an inadvertent parent cleanup. + * Deletes the rows in a single ``_execute_write`` callback so + the operation is atomic — a partial failure (e.g. SIGKILL + mid-loop) doesn't leave the DB in a "messages-deleted but + session-row-still-there" half-state. + * Cleans up on-disk transcript files (``.json`` / ``.jsonl`` / + ``request_dump_*``) outside the DB transaction when + ``sessions_dir`` is provided. Empty sessions don't typically + have transcript files, but the gateway can leave a stub + ``request_dump_*`` if it crashed before the first reply — + so we still sweep, matching ``prune_sessions``. + + Returns the number of sessions deleted. + """ + removed_ids: list[str] = [] + + def _do(conn): + cursor = conn.execute( + "SELECT id FROM sessions " + "WHERE message_count = 0 " + "AND ended_at IS NOT NULL " + "AND archived = 0" + ) + session_ids = {row["id"] for row in cursor.fetchall()} + + if not session_ids: + return 0 + + placeholders = ",".join("?" * len(session_ids)) + conn.execute( + f"UPDATE sessions SET parent_session_id = NULL " + f"WHERE parent_session_id IN ({placeholders})", + list(session_ids), + ) + + for sid in session_ids: + # DELETE FROM messages is paranoia — by construction + # these rows have ``message_count = 0`` — but if a + # bookkeeping bug ever lets the counter drift below the + # real row count, we still leave a clean FK state. + conn.execute( + "DELETE FROM messages WHERE session_id = ?", (sid,) + ) + conn.execute("DELETE FROM sessions WHERE id = ?", (sid,)) + removed_ids.append(sid) + return len(session_ids) + + count = self._execute_write(_do) + for sid in removed_ids: + self._remove_session_files(sessions_dir, sid) + return count + def prune_sessions( self, older_than_days: int = 90, diff --git a/plugins/example-dashboard/dashboard/plugin_api.py b/plugins/example-dashboard/dashboard/plugin_api.py deleted file mode 100644 index 3e850298a..000000000 --- a/plugins/example-dashboard/dashboard/plugin_api.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Example dashboard plugin — backend API routes. - -Mounted at /api/plugins/example/ by the dashboard plugin system. - -This minimal plugin exists so the test suite has a stable, side-effect-free -GET endpoint to verify that plugin API routes work with auth. -""" - -from fastapi import APIRouter - -router = APIRouter() - - -@router.get("/hello") -async def hello(): - """Simple greeting endpoint to demonstrate plugin API routes.""" - return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"} diff --git a/plugins/example-dashboard/dashboard/manifest.json b/tests/fixtures/plugins/example-dashboard/dashboard/manifest.json similarity index 59% rename from plugins/example-dashboard/dashboard/manifest.json rename to tests/fixtures/plugins/example-dashboard/dashboard/manifest.json index 68a2e9b89..12b9946bf 100644 --- a/plugins/example-dashboard/dashboard/manifest.json +++ b/tests/fixtures/plugins/example-dashboard/dashboard/manifest.json @@ -1,7 +1,7 @@ { "name": "example", "label": "Example", - "description": "Example dashboard plugin — used by test suite for auth coverage", + "description": "Test-only dashboard plugin fixture — installed by tests that need a stable plugin API endpoint to verify auth + static-asset behaviour", "icon": "Sparkles", "version": "1.0.0", "tab": { diff --git a/tests/fixtures/plugins/example-dashboard/dashboard/plugin_api.py b/tests/fixtures/plugins/example-dashboard/dashboard/plugin_api.py new file mode 100644 index 000000000..21c4b05d6 --- /dev/null +++ b/tests/fixtures/plugins/example-dashboard/dashboard/plugin_api.py @@ -0,0 +1,24 @@ +"""Example dashboard plugin — backend API routes (test fixture). + +This plugin lives under ``tests/fixtures/plugins/`` so it is NOT shipped as +part of the bundled-plugins set; a stock hermes-agent install does not see +an "Example" tab in its sidebar. The ``_install_example_plugin`` pytest +fixture in ``tests/hermes_cli/test_web_server.py`` copies this directory +into ``$HERMES_HOME/plugins/example-dashboard/`` and forces the dashboard +plugin discovery cache to rescan, so tests that need a stable, side-effect- +free GET endpoint to verify plugin API auth + static-asset behaviour can +hit ``/api/plugins/example/hello`` (and ``/dashboard-plugins/example/ +manifest.json``) without depending on any production-facing plugin. + +Mounted at /api/plugins/example/ by the dashboard plugin system. +""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/hello") +async def hello(): + """Simple greeting endpoint to demonstrate plugin API routes.""" + return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"} diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index d994797e4..06079aed3 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2,6 +2,7 @@ import os import json +import shutil from pathlib import Path from unittest.mock import patch, MagicMock @@ -14,6 +15,97 @@ from hermes_cli.config import ( ) +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +# Path to the test-only example-dashboard plugin. Lives under +# tests/fixtures/ so the bundled-plugins directory stays clean — stock +# installs no longer ship a dummy "Example" sidebar tab. Tests that +# depend on its routes opt in via the `_install_example_plugin` fixture +# below. +_EXAMPLE_PLUGIN_FIXTURE = ( + Path(__file__).resolve().parent.parent / "fixtures" / "plugins" / "example-dashboard" +) + + +@pytest.fixture +def _install_example_plugin(_isolate_hermes_home): + """Drop the example-dashboard fixture into the per-test HERMES_HOME + user-plugins directory and force the web_server's dashboard plugin + cache + API mount to rediscover it. + + The plugin used to live under ``/plugins/example-dashboard/`` + and was loaded for every install, putting an "Example" tab in every + user's sidebar. It is now a tests-only fixture: any test that needs + ``/api/plugins/example/hello`` or ``/dashboard-plugins/example/...`` + requests this fixture so the plugin appears only for that test's + isolated ``HERMES_HOME``. + + The user-plugin source is preferred over a transient + ``HERMES_BUNDLED_PLUGINS`` override because the bundled dir is + resolved per-call (other tests in the suite implicitly rely on the + real bundled plugins — kanban, hermes-achievements, model providers + — being available, and globally swapping that root would yank them + all). User plugins are first in the discovery search order, so + laying down the fixture here is enough. + """ + from hermes_constants import get_hermes_home + from hermes_cli import web_server + + user_plugins_dir = get_hermes_home() / "plugins" + user_plugins_dir.mkdir(parents=True, exist_ok=True) + dst = user_plugins_dir / "example-dashboard" + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst) + + # Snapshot the existing routes BEFORE mounting so we can: + # 1. Identify the routes the mount call appends. + # 2. Restore the original list on teardown — otherwise leftover + # ``/api/plugins/example/*`` routes leak into subsequent tests + # and start serving requests against a torn-down HERMES_HOME. + app = web_server.app + original_routes = list(app.router.routes) + + # Bust the module-level cache and re-discover so the example plugin + # shows up in `_get_dashboard_plugins()`. `_mount_plugin_api_routes` + # imports the plugin's `plugin_api.py` and ``include_router``s its + # FastAPI router under ``/api/plugins/example/*``. The static-asset + # route at ``/dashboard-plugins//`` reads the plugins + # list dynamically per request, so the rescan alone is enough for + # the static-asset tests; the API auth tests additionally need the + # route reorder below. + web_server._dashboard_plugins_cache = None + web_server._get_dashboard_plugins(force_rescan=True) + web_server._mount_plugin_api_routes() + + # ``include_router`` appends the new routes to the END of + # ``app.router.routes``. That works fine at import time — the SPA + # catch-all ``mount_spa(app)`` registers AFTER the initial mount + # call — but when we mount mid-flight the catch-all is already in + # place, so the new ``/api/plugins/example/*`` route loses the + # match-order race and we get a 404. Move the newly-appended routes + # to the front of the list so FastAPI matches them first. They're + # path-prefixed to ``/api/plugins/example/`` and can't shadow + # anything else. + new_routes = [r for r in app.router.routes if r not in original_routes] + for route in new_routes: + app.router.routes.remove(route) + for offset, route in enumerate(new_routes): + app.router.routes.insert(offset, route) + + try: + yield + finally: + # Restore the original route list — drops the example plugin's + # routes so the next test sees a clean app — and clear the + # cache for the same reason. + app.router.routes[:] = original_routes + web_server._dashboard_plugins_cache = None + + # --------------------------------------------------------------------------- # reload_env tests # --------------------------------------------------------------------------- @@ -2551,12 +2643,284 @@ class TestNormaliseThemeExtensions: assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"} +class TestBulkDeleteSessionsEndpoint: + """Tests for ``POST /api/sessions/bulk-delete`` — backs the + dashboard's "Delete N selected" flow on the sessions page. + + Locks in four things: + + 1. Route-ordering: ``/api/sessions/bulk-delete`` must shadow the + templated ``/api/sessions/{session_id}`` route below it (see + the block comment in ``hermes_cli/web_server.py``). + 2. Behaviour parity with :meth:`SessionDB.delete_sessions` — real + deleted count, archive/active sessions deleted on explicit + selection. + 3. The 500-ID payload cap is enforced. + 4. Auth gating (issue #19533 contract). + """ + + @pytest.fixture(autouse=True) + def _setup_test_client(self, monkeypatch, _isolate_hermes_home): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + monkeypatch.setattr( + hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db" + ) + + self.client = TestClient(app) + self.auth_client = TestClient(app) + self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + def _seed(self, ids): + from hermes_state import SessionDB + + db = SessionDB() + try: + for sid in ids: + db.create_session(session_id=sid, source="cli") + finally: + db.close() + + def test_requires_auth(self): + resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]}) + assert resp.status_code == 401 + + def test_deletes_listed_sessions_only(self): + from hermes_state import SessionDB + + self._seed(["a", "b", "c"]) + resp = self.auth_client.post( + "/api/sessions/bulk-delete", json={"ids": ["a", "b"]} + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "deleted": 2} + + db = SessionDB() + try: + assert db.get_session("a") is None + assert db.get_session("b") is None + assert db.get_session("c") is not None + finally: + db.close() + + def test_unknown_ids_silently_skipped(self): + """The endpoint never 404s on a missing ID — it returns the + real deleted count so a UI selection that raced against + another tab still resolves cleanly.""" + self._seed(["real"]) + resp = self.auth_client.post( + "/api/sessions/bulk-delete", + json={"ids": ["real", "ghost1", "ghost2"]}, + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "deleted": 1} + + def test_empty_list_is_noop(self): + """``ids: []`` returns ``deleted: 0`` (200, not 400) — the UI + treats an empty selection as a no-op rather than an error.""" + resp = self.auth_client.post( + "/api/sessions/bulk-delete", json={"ids": []} + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "deleted": 0} + + def test_payload_cap_enforced(self): + """501 IDs returns 400 — a hard cap stops a runaway selection + from holding the SQLite writer for an extended window.""" + resp = self.auth_client.post( + "/api/sessions/bulk-delete", + json={"ids": [f"s{i}" for i in range(501)]}, + ) + assert resp.status_code == 400 + # 500 exactly still succeeds (no rows actually present, so + # deleted=0 — but it's not the cap path). + resp = self.auth_client.post( + "/api/sessions/bulk-delete", + json={"ids": [f"s{i}" for i in range(500)]}, + ) + assert resp.status_code == 200 + + def test_route_order_not_shadowed_by_session_id(self): + """Pin the route-ordering contract: ``POST /api/sessions/bulk-delete`` + must hit the bulk handler, not be re-interpreted via the + templated ``/api/sessions/{session_id}`` family. Concretely the + response carries our ``ok`` + ``deleted`` keys.""" + resp = self.auth_client.post( + "/api/sessions/bulk-delete", json={"ids": []} + ) + assert resp.status_code == 200 + body = resp.json() + assert body.get("ok") is True + assert "deleted" in body, ( + "If this assertion fails, /api/sessions/bulk-delete is " + "being shadowed by /api/sessions/{session_id} — check " + "registration order in hermes_cli/web_server.py." + ) + + +class TestDeleteEmptySessionsEndpoint: + """Tests for ``GET /api/sessions/empty/count`` and + ``DELETE /api/sessions/empty`` — the bulk-delete endpoints backing + the dashboard's "Delete empty" button. + + Locks in three things the implementation has to get right: + + 1. Route-ordering: the literal ``/api/sessions/empty[/count]`` paths + must shadow the templated ``/api/sessions/{session_id}`` route + above them. A regression here would route ``DELETE /api/sessions/ + empty`` to the single-session handler with ``session_id="empty"`` + (which 404s instead of bulk-deleting). + 2. Behaviour parity with :meth:`SessionDB.delete_empty_sessions`: + active sessions and archived sessions are both preserved. + 3. Auth gating: both routes require the session token like every + other ``/api/*`` endpoint (issue #19533 contract). + """ + + @pytest.fixture(autouse=True) + def _setup_test_client(self, monkeypatch, _isolate_hermes_home): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + # Pin the SessionDB to the isolated HERMES_HOME so each test + # starts with a clean state.db. + monkeypatch.setattr( + hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db" + ) + + self.client = TestClient(app) + self.auth_client = TestClient(app) + self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + def _seed(self): + """Build the standard test corpus: + + * ``empty1`` / ``empty2`` — ended, no messages → should delete + * ``hasmsg`` — ended, has one message → must survive + * ``live`` — un-ended, empty → must survive (active) + * ``archived``— ended, empty, archived → must survive + """ + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="empty1", source="cli") + db.end_session("empty1", end_reason="done") + db.create_session(session_id="empty2", source="cli") + db.end_session("empty2", end_reason="done") + + db.create_session(session_id="hasmsg", source="cli") + db.append_message("hasmsg", role="user", content="hello") + db.end_session("hasmsg", end_reason="done") + + db.create_session(session_id="live", source="cli") + + db.create_session(session_id="archived", source="cli") + db.end_session("archived", end_reason="done") + db.set_session_archived("archived", True) + finally: + db.close() + + def test_count_endpoint_requires_auth(self): + """GET /api/sessions/empty/count must 401 without the session token.""" + resp = self.client.get("/api/sessions/empty/count") + assert resp.status_code == 401 + + def test_delete_endpoint_requires_auth(self): + """DELETE /api/sessions/empty must 401 without the session token. + + Regression guard for issue #19533 — the bulk-delete is a strictly + destructive primitive, the middleware must gate it even if a + future refactor introduces a non-auth path.""" + resp = self.client.delete("/api/sessions/empty") + assert resp.status_code == 401 + + def test_count_returns_only_empty_ended_unarchived(self): + """With the standard corpus, the count is exactly 2 — only + ``empty1`` and ``empty2`` qualify (``hasmsg`` has a message, + ``live`` is active, ``archived`` is archived).""" + self._seed() + resp = self.auth_client.get("/api/sessions/empty/count") + assert resp.status_code == 200 + assert resp.json() == {"count": 2} + + def test_delete_returns_count_and_removes_only_empties(self): + """DELETE returns the deleted count and removes only the + empty-ended-unarchived rows — same shape contract as the + DB-level method's unit tests.""" + from hermes_state import SessionDB + + self._seed() + resp = self.auth_client.delete("/api/sessions/empty") + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "deleted": 2} + + db = SessionDB() + try: + assert db.get_session("empty1") is None + assert db.get_session("empty2") is None + # Survivors: hasmsg has a message, live is active, archived + # is archived. All three must still be there. + assert db.get_session("hasmsg") is not None + assert db.get_session("live") is not None + assert db.get_session("archived") is not None + # And the count endpoint now reports 0. + assert db.count_empty_sessions() == 0 + finally: + db.close() + + def test_delete_with_no_empties_returns_zero(self): + """No empty sessions → endpoint returns ``deleted: 0`` (200, + not 404). The dashboard relies on this no-op path to surface + a "Nothing to clean up" toast instead of an error.""" + resp = self.auth_client.delete("/api/sessions/empty") + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "deleted": 0} + + def test_route_order_empty_not_shadowed_by_session_id(self): + """Pin the route-ordering contract: ``DELETE /api/sessions/empty`` + must hit the bulk handler, not the templated single-session + handler (which would 404 because no session has id 'empty'). + + Concretely: a request against the bulk path on an EMPTY corpus + returns ``{ok: True, deleted: 0}``. If the templated route were + winning, we'd see 404 ("Session not found") instead. + """ + resp = self.auth_client.delete("/api/sessions/empty") + assert resp.status_code == 200 + body = resp.json() + assert "deleted" in body, ( + "If this assertion fails, the literal /api/sessions/empty " + "route is being shadowed by the templated /api/sessions/" + "{session_id} route — check registration order in " + "hermes_cli/web_server.py." + ) + + class TestPluginAPIAuth: """Tests that plugin API routes require the session token (issue #19533).""" @pytest.fixture(autouse=True) - def _setup_test_client(self, monkeypatch, _isolate_hermes_home): - """Create a TestClient without the session token header.""" + def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin): + """Create a TestClient without the session token header. + + Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth`` + has the ``/api/plugins/example/hello`` endpoint available — the + example plugin is no longer a bundled plugin, so the fixture + installs it into the per-test ``HERMES_HOME``. + """ try: from starlette.testclient import TestClient except ImportError: @@ -2581,10 +2945,12 @@ class TestPluginAPIAuth: def test_plugin_route_allows_auth(self): """Plugin API routes should work with a valid session token. - Use ``/api/plugins/example/hello`` from the example-dashboard plugin — - a stable, side-effect-free GET that's always loaded in tests. With a - valid token the handler should run (200); without one the middleware - should 401 before the handler is reached. + Uses ``/api/plugins/example/hello`` from the example-dashboard + test fixture (installed into HERMES_HOME by the class-level + ``_install_example_plugin`` fixture) — a stable, side-effect-free + GET that's only loaded for tests. With a valid token the handler + should run (200); without one the middleware should 401 before + the handler is reached. """ # Without auth: middleware blocks before reaching the handler. resp = self.client.get("/api/plugins/example/hello") @@ -3136,7 +3502,16 @@ class TestDashboardPluginStaticAssetAllowlist: """ @pytest.fixture(autouse=True) - def _setup_test_client(self, monkeypatch, _isolate_hermes_home): + def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin): + """Create a TestClient and install the example-dashboard fixture. + + The static-asset allowlist tests need a plugin to point at — + they verify that ``/dashboard-plugins/example/manifest.json`` + is served while ``plugin_api.py`` and ``__pycache__/*.pyc`` + from the same directory are not. Since the example plugin is + no longer bundled, ``_install_example_plugin`` lays it down in + the per-test ``HERMES_HOME`` user-plugins dir. + """ try: from starlette.testclient import TestClient except ImportError: diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 75f782ef6..572fd6489 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1523,6 +1523,243 @@ class TestDeleteSessionOrphansChildren: assert grandchild["parent_session_id"] == "child" +class TestBulkDeleteSessions: + """``delete_sessions(ids)`` — the bulk-delete primitive backing the + sessions-page "Delete N selected" button. Per-row contract matches + :meth:`SessionDB.delete_session` (children orphaned, not cascade- + deleted), but applied across the whole list in one transaction. + + Invariants this class locks in: + + 1. Returns the real deleted count (existing intersection), not + just ``len(session_ids)`` — selection state in the UI can race + against another tab's delete. + 2. Unknown IDs are silently skipped, never raise. + 3. ``message_count > 0`` sessions are deleted too — unlike + ``delete_empty_sessions``, the user explicitly picked them, so + we trust the selection. + 4. Live (un-ended) and archived sessions ARE deleted on explicit + selection (no bulk-sweep safety guards apply when the user + hand-picks the row). + 5. Children of any deleted parent are orphaned, even when the + parent is mid-list. + 6. ``[]`` / ``None``-laden lists are safe no-ops. + """ + + def test_deletes_listed_sessions(self, db): + db.create_session(session_id="a", source="cli") + db.append_message("a", role="user", content="hi") + db.create_session(session_id="b", source="cli") + db.create_session(session_id="c", source="cli") + + deleted = db.delete_sessions(["a", "b"]) + assert deleted == 2 + assert db.get_session("a") is None + assert db.get_session("b") is None + # Unlisted survives. + assert db.get_session("c") is not None + + def test_returns_real_count_skipping_unknown_ids(self, db): + """Unknown IDs are silently skipped — the return value reflects + what was *actually* deleted, so the UI can show an accurate + toast even if the selection raced against another tab.""" + db.create_session(session_id="real", source="cli") + + deleted = db.delete_sessions(["real", "ghost1", "ghost2"]) + assert deleted == 1 + assert db.get_session("real") is None + + def test_empty_list_is_noop(self, db): + """``[]`` returns 0 without touching the DB. Guards against a + bulk endpoint with an empty payload triggering an + unconditional 'wipe everything' if the caller forgets the + WHERE clause.""" + db.create_session(session_id="keep", source="cli") + assert db.delete_sessions([]) == 0 + assert db.get_session("keep") is not None + + def test_drops_non_string_entries(self, db): + """Stray ``None`` / empty strings in the input list are + filtered out before hitting SQL. Callers may pull selection IDs + from a Set-like that occasionally contains noise; we don't want + a SQL parameter-type error to fail the whole batch.""" + db.create_session(session_id="real", source="cli") + # noinspection PyTypeChecker + deleted = db.delete_sessions(["real", None, "", "ghost"]) # type: ignore[list-item] + assert deleted == 1 + assert db.get_session("real") is None + + def test_dedupes_duplicate_ids(self, db): + """The same ID listed twice counts as one deletion. Defends + against a hand-crafted POST body or a UI bug that double-adds + the same selection.""" + db.create_session(session_id="real", source="cli") + deleted = db.delete_sessions(["real", "real"]) + assert deleted == 1 + + def test_orphans_children_of_deleted_parents(self, db): + """Bulk-deleting a parent leaves its children alive but + re-parented to NULL. Same contract as the single-session + :meth:`delete_session` path.""" + db.create_session(session_id="parent", source="cli") + db.create_session( + session_id="child", source="cli", parent_session_id="parent" + ) + + deleted = db.delete_sessions(["parent"]) + assert deleted == 1 + child = db.get_session("child") + assert child is not None + assert child["parent_session_id"] is None + + def test_deletes_archived_and_active_when_selected(self, db): + """Unlike the safety-gated ``delete_empty_sessions`` sweep, + explicit bulk-select trusts the user — archived sessions and + un-ended live sessions are both deleted when in the list. + Otherwise the selection UI would silently 'leak' rows the user + thought they'd removed.""" + db.create_session(session_id="archived", source="cli") + db.end_session("archived", end_reason="done") + db.set_session_archived("archived", True) + db.create_session(session_id="live", source="cli") + + deleted = db.delete_sessions(["archived", "live"]) + assert deleted == 2 + assert db.get_session("archived") is None + assert db.get_session("live") is None + + def test_cleans_up_transcript_files(self, db, tmp_path): + """When ``sessions_dir`` is provided, on-disk transcripts are + swept as part of the bulk operation — mirrors the per-row + :meth:`delete_session(sessions_dir=...)` behaviour so the + bulk-delete CLI / web flows don't leak files.""" + db.create_session(session_id="s1", source="cli") + db.create_session(session_id="s2", source="cli") + (tmp_path / "s1.jsonl").write_text("") + (tmp_path / "s2.json").write_text("{}") + + deleted = db.delete_sessions(["s1", "s2"], sessions_dir=tmp_path) + assert deleted == 2 + assert not (tmp_path / "s1.jsonl").exists() + assert not (tmp_path / "s2.json").exists() + + +class TestDeleteEmptySessions: + """``delete_empty_sessions`` sweeps every ended, non-archived session + whose ``message_count`` is 0. Backs the dashboard's "Delete empty" + button — see ``SessionsPage.tsx`` + ``DELETE /api/sessions/empty`` + in ``hermes_cli/web_server.py``. + + Invariants this class locks in: + + 1. Only ``message_count = 0`` rows are touched. + 2. Active (un-ended) sessions are skipped even if they're empty — + the agent might be mid-handshake, and yanking the row would + race the live runtime. + 3. Archived sessions are skipped — the user already filed them away. + 4. Children of a deleted parent are orphaned (parent_session_id → + NULL) rather than cascade-deleted, matching the + ``delete_session`` / ``prune_sessions`` contract. + 5. The pre-DB count matches the post-DB delete return value. + """ + + def test_count_and_delete_empties_only(self, db): + # Two empty + ended sessions → both should be in the kill list. + db.create_session(session_id="empty1", source="cli") + db.end_session("empty1", end_reason="done") + db.create_session(session_id="empty2", source="cli") + db.end_session("empty2", end_reason="done") + + # One non-empty + ended session → must survive. + db.create_session(session_id="hasmsg", source="cli") + db.append_message("hasmsg", role="user", content="Hello") + db.end_session("hasmsg", end_reason="done") + + assert db.count_empty_sessions() == 2 + + deleted = db.delete_empty_sessions() + assert deleted == 2 + assert db.get_session("empty1") is None + assert db.get_session("empty2") is None + assert db.get_session("hasmsg") is not None + assert db.count_empty_sessions() == 0 + + def test_skips_active_empty_sessions(self, db): + """A live (un-ended) empty session is what you get during the + race between session-create and the first message landing. The + sweep must not delete it — that would yank a session out from + under the agent before its first reply persists.""" + db.create_session(session_id="live", source="cli") + # Deliberately no end_session() — session is "active". + + assert db.count_empty_sessions() == 0 + assert db.delete_empty_sessions() == 0 + assert db.get_session("live") is not None + + def test_skips_archived_empty_sessions(self, db): + """Archived = soft-hidden by the user. They explicitly chose to + keep the row around (even though it's empty), so the bulk sweep + must not surprise them by deleting it. Restoring an archived + session is one click; resurrecting one we deleted is impossible.""" + db.create_session(session_id="archived_empty", source="cli") + db.end_session("archived_empty", end_reason="done") + db.set_session_archived("archived_empty", True) + + assert db.count_empty_sessions() == 0 + assert db.delete_empty_sessions() == 0 + assert db.get_session("archived_empty") is not None + + def test_returns_zero_when_nothing_to_delete(self, db): + """No-op path: no candidate rows → return 0, no error.""" + db.create_session(session_id="hasmsg", source="cli") + db.append_message("hasmsg", role="user", content="Hello") + db.end_session("hasmsg", end_reason="done") + + assert db.count_empty_sessions() == 0 + assert db.delete_empty_sessions() == 0 + assert db.get_session("hasmsg") is not None + + def test_orphans_children_of_deleted_empty_parent(self, db): + """Even an empty parent can have a child (e.g. a branch session + spawned before the parent received any messages). The sweep + must orphan that child, not cascade-delete it — same contract + as ``delete_session`` and ``prune_sessions``.""" + db.create_session(session_id="empty_parent", source="cli") + db.end_session("empty_parent", end_reason="done") + db.create_session( + session_id="child", source="cli", parent_session_id="empty_parent" + ) + db.append_message("child", role="user", content="something") + db.end_session("child", end_reason="done") + + deleted = db.delete_empty_sessions() + assert deleted == 1 + assert db.get_session("empty_parent") is None + child = db.get_session("child") + assert child is not None + assert child["parent_session_id"] is None + + def test_cleans_up_on_disk_transcript_files(self, db, tmp_path): + """When ``sessions_dir`` is provided, transcript files left + behind by a crashed gateway (``request_dump_*.json``) are swept + too. Empty sessions rarely have ``{id}.json`` / ``.jsonl`` + transcripts, but the request-dump path is real — the gateway + writes one before the first reply lands, so a crash mid-reply + produces an empty session with a non-empty dump file.""" + db.create_session(session_id="empty_with_dump", source="cli") + db.end_session("empty_with_dump", end_reason="done") + + dump = tmp_path / "request_dump_empty_with_dump_0.json" + dump.write_text("{}") + transcript = tmp_path / "empty_with_dump.jsonl" + transcript.write_text("") + + deleted = db.delete_empty_sessions(sessions_dir=tmp_path) + assert deleted == 1 + assert not dump.exists() + assert not transcript.exists() + + # ========================================================================= # Schema and WAL mode # ========================================================================= diff --git a/web/src/components/Backdrop.tsx b/web/src/components/Backdrop.tsx index d7471c4c2..278ff7b9e 100644 --- a/web/src/components/Backdrop.tsx +++ b/web/src/components/Backdrop.tsx @@ -11,11 +11,27 @@ import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp"; * and the warm vignette both read theme-switchable CSS custom properties so * `ThemeProvider` can repaint the stack without remounting. * - * z-1 bg = `var(--background-base)`, mix-blend-mode: difference + * z-1 bg = `var(--background-base)`, mix-blend-mode driven by + * `--component-backdrop-bg-blend-mode` (default `difference`). + * Both LENS_0-style dark themes and the LENS_5I-style Nous Blue + * light theme keep `difference` here — the canvas is flipped by + * the z-200 FG inversion layer, not by changing this blend mode. + * The CSS var is exposed as a hook so future presets can override + * it (e.g. `multiply` to paint the bg as-is before inversion) + * without touching this component. * z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference * z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten - * z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`, - * color-dodge) — gated on GPU tier + * z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I, + * alpha-0 in LENS_0), mix-blend-mode: difference. This is the + * layer that flips the dashboard into "light mode" for inverted + * themes; for normal dark themes its alpha is 0 so it's a no-op. + * Deliberately placed above every UI overlay z-index (modals, + * tooltips, and dropUp dropdowns all sit at z-[100]) so portaled + * elements get inverted along with the rest of the page instead + * of painting with pre-inversion colors on top of the lens. + * z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`, + * color-dodge) — gated on GPU tier. Sits above the inversion + * layer by design so the grain is not flipped. * * `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a * software rasterizer (SwiftShader/llvmpipe), or the user has @@ -31,10 +47,13 @@ export function Backdrop() {
+ {/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity + + fgBlend: 'difference' in `design-language/src/ui/components/ + overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default) + the layer is fully transparent and contributes nothing; with + alpha 1 + opaque white it inverts the entire stack below it, + producing the LENS_5I "light mode" look without altering any + downstream component code. + + z-200 (not 100) so it sits above every portaled UI overlay — + sidebar tooltips, dropUp dropdowns, and modal dialogs all use + z-[100], which is what the DS Lens picks too; portals append + at the end of , so equal z-index + later DOM order means + they'd paint on top of the inversion and skip the flip. Inlined + z-index for the same reason the DS does it — Tailwind's JIT + scan sometimes drops non-default z utilities. */} +
+ {gpuTier > 0 && (
{caps.supports_tools && ( - + Tools )} diff --git a/web/src/components/ScheduleBuilder.tsx b/web/src/components/ScheduleBuilder.tsx new file mode 100644 index 000000000..134841857 --- /dev/null +++ b/web/src/components/ScheduleBuilder.tsx @@ -0,0 +1,273 @@ +import { useCallback } from "react"; +import { Input } from "@nous-research/ui/ui/components/input"; +import { Label } from "@nous-research/ui/ui/components/label"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { useI18n } from "@/i18n"; +import { + buildScheduleString, + DEFAULT_SCHEDULE_STATE, + type IntervalUnit, + type ScheduleBuilderState, + type ScheduleMode, + type Weekday, + WEEKDAY_INDEXES, +} from "@/lib/schedule"; + +/** + * Human-readable schedule picker for cron job create/edit flows. + * + * Replaces the raw "type a cron expression" input that lived inline in + * ``CronPage``. The picker still emits a single backend-compatible + * schedule string (see ``cron/jobs.py::parse_schedule``), but the user + * fills out shape-appropriate inputs (time picker, weekday toggles, + * datetime-local field) per mode. + * + * Architecture: + * + * - The component is fully controlled. Parent owns the + * ``ScheduleBuilderState`` and the derived schedule string (built + * via ``buildScheduleString`` in render). + * - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are + * preserved across mode switches so flipping back to a previous mode + * doesn't erase the user's work. + * - The "Custom" mode is an escape hatch — surfacing it as a normal + * option (instead of hiding it behind an "advanced" toggle) keeps + * power-user workflows discoverable without making everyone scroll + * past it. + */ +export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) { + const { t } = useI18n(); + const cronStrings = t.cron; + const modeStrings = cronStrings.scheduleModes; + + const update = useCallback( + (patch: Partial) => { + onChange({ ...value, ...patch }); + }, + [onChange, value], + ); + + const toggleWeekday = useCallback( + (day: Weekday) => { + const present = value.weekdays.includes(day); + update({ + weekdays: present + ? value.weekdays.filter((d) => d !== day) + : [...value.weekdays, day], + }); + }, + [update, value.weekdays], + ); + + return ( +
+
+ + +
+ + {value.mode === "interval" && ( +
+
+ + { + const n = parseInt(e.target.value, 10); + update({ + intervalValue: Number.isFinite(n) && n > 0 ? n : 1, + }); + }} + /> +
+
+ + +
+
+ )} + + {value.mode === "daily" && ( + update({ timeOfDay })} + /> + )} + + {value.mode === "weekly" && ( + <> +
+ +
+ {WEEKDAY_INDEXES.map((d) => { + const isOn = value.weekdays.includes(d); + return ( + + ); + })} +
+
+ update({ timeOfDay })} + /> + + )} + + {value.mode === "monthly" && ( +
+
+ + { + const n = parseInt(e.target.value, 10); + update({ + dayOfMonth: + Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1, + }); + }} + /> +
+ update({ timeOfDay })} + /> +
+ )} + + {value.mode === "once" && ( +
+ + {/* Native datetime-local — emits the exact "YYYY-MM-DDTHH:MM" + shape ``parse_schedule`` accepts on the backend. */} + update({ onceAt: e.target.value })} + /> +
+ )} + + {value.mode === "custom" && ( +
+ + update({ custom: e.target.value })} + className="font-mono-ui" + /> +

+ {modeStrings.customHint} +

+
+ )} + + {/* Inline preview of what we'll send to the backend. Helps users + eyeball the result before hitting Create, and keeps the + schedule grammar discoverable for the custom mode. */} +

+ {modeStrings.preview}: + + {buildScheduleString(value) || modeStrings.previewEmpty} + +

+
+ ); +} + +function TimeOfDayField({ + id, + label, + onChange, + value, +}: TimeOfDayFieldProps) { + return ( +
+ + {/* Native time picker is the right tool for "HH:MM" — saves us + two separate hour/minute selects, respects user locale's + AM/PM preference, and round-trips with ``buildScheduleString`` + without parsing. */} + onChange(e.target.value)} + /> +
+ ); +} + +export { DEFAULT_SCHEDULE_STATE }; + +interface ScheduleBuilderProps { + onChange: (state: ScheduleBuilderState) => void; + value: ScheduleBuilderState; +} + +interface TimeOfDayFieldProps { + id: string; + label: string; + onChange: (value: string) => void; + value: string; +} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index 66cf9b0c5..161175bbf 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -208,15 +208,25 @@ function ThemeSwitcherOptions({ } function ThemeSwatch({ theme }: { theme: DashboardTheme }) { - const { background, midground, warmGlow } = theme.palette; + // Inverted themes (Nous Blue / future lens themes) author their palette + // pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground- + // difference layer flips the page. The picker can't replay that math + // cheaply, so themes opt-in to an explicit `swatchColors` triplet that + // mirrors the on-screen result. Falls back to the raw palette hexes for + // every other theme so existing dark-theme swatches are untouched. + const [c1, c2, c3] = theme.swatchColors ?? [ + theme.palette.background.hex, + theme.palette.midground.hex, + theme.palette.warmGlow, + ]; return (
- - - + + +
); } diff --git a/web/src/components/ToolCall.tsx b/web/src/components/ToolCall.tsx index 0c599d2d6..c17a60d8e 100644 --- a/web/src/components/ToolCall.tsx +++ b/web/src/components/ToolCall.tsx @@ -220,7 +220,7 @@ function colorizeDiff(diff: string): React.ReactNode { function diffLineClass(line: string): string { if (line.startsWith("+") && !line.startsWith("+++")) - return "text-emerald-500 dark:text-emerald-400"; + return "text-success"; if (line.startsWith("-") && !line.startsWith("---")) return "text-destructive"; if (line.startsWith("@@")) return "text-primary"; diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts index c3d6312aa..5d5dc0042 100644 --- a/web/src/i18n/af.ts +++ b/web/src/i18n/af.ts @@ -141,6 +141,22 @@ export const af: Translations = { "Dit verwyder die gesprek en al sy boodskappe permanent. Dit kan nie ongedaan gemaak word nie.", sessionDeleted: "Sessie geskrap", failedToDelete: "Kon nie sessie skrap nie", + deleteEmpty: "Skrap leë", + deleteEmptyConfirmTitle: "Skrap leë sessies?", + deleteEmptyConfirmMessage: + "Dit verwyder permanent {count} sessies wat geen boodskappe het nie. Aktiewe en geargiveerde sessies word oorgeslaan. Dit kan nie ongedaan gemaak word nie.", + emptySessionsDeleted: "{count} leë sessies geskrap", + failedToDeleteEmpty: "Kon nie leë sessies skrap nie", + selectSession: "Kies sessie", + selectAllOnPage: "Kies alles op hierdie bladsy", + clearSelection: "Maak keuse skoon", + selectedCount: "{count} gekies", + deleteSelected: "Skrap {count}", + deleteSelectedConfirmTitle: "Skrap {count} sessies?", + deleteSelectedConfirmMessage: + "Dit verwyder {count} gekose sessies en al hul boodskappe permanent. Dit kan nie ongedaan gemaak word nie.", + selectedSessionsDeleted: "{count} sessies geskrap", + failedToDeleteSelected: "Kon nie gekose sessies skrap nie", resumeInChat: "Hervat in Klets", previousPage: "Vorige bladsy", nextPage: "Volgende bladsy", @@ -211,6 +227,41 @@ export const af: Translations = { promptPlaceholder: "Wat moet die agent met elke uitvoering doen?", schedule: "Skedule (cron-uitdrukking)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Skedule", + scheduleModes: { + interval: "Herhalende interval", + daily: "Daagliks", + weekly: "Weekliks", + monthly: "Maandeliks", + once: "Een keer", + custom: "Pasgemaak (cron-uitdrukking)", + intervalEvery: "Elke", + intervalUnit: "Eenheid", + unitMinutes: "minute", + unitHours: "ure", + unitDays: "dae", + timeOfDay: "Tyd van die dag", + weekdays: "Dae van die week", + weekdaysShort: ["Son", "Maa", "Din", "Woe", "Don", "Vry", "Sat"], + dayOfMonth: "Dag van die maand", + onceAt: "Hardloop op", + customLabel: "Cron-uitdrukking", + customPlaceholder: "0 9 * * *", + customHint: + "Cron-uitdrukking met vyf velde (minuut, uur, dag, maand, weekdag).", + preview: "Word gestuur as", + previewEmpty: "(onvolledig)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Elke {n} min", + everyHours: "Elke {n} u", + everyDays: "Elke {n} d", + dailyAt: "Daagliks om {time}", + weeklyAt: "Weekliks op {days} om {time}", + monthlyAt: "Maandeliks op die {day} om {time}", + onceAt: "Een keer op {time}", + }, deliverTo: "Lewer aan", scheduledJobs: "Geskeduleerde Take", noJobs: "Geen cron-take gekonfigureer nie. Skep een hierbo.", diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index d6fdfe645..e2eb1429c 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -141,6 +141,22 @@ export const de: Translations = { "Dies entfernt die Unterhaltung und alle Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.", sessionDeleted: "Sitzung gelöscht", failedToDelete: "Sitzung konnte nicht gelöscht werden", + deleteEmpty: "Leere löschen", + deleteEmptyConfirmTitle: "Leere Sitzungen löschen?", + deleteEmptyConfirmMessage: + "Dies entfernt dauerhaft {count} Sitzungen ohne Nachrichten. Aktive und archivierte Sitzungen werden übersprungen. Dies kann nicht rückgängig gemacht werden.", + emptySessionsDeleted: "{count} leere Sitzungen gelöscht", + failedToDeleteEmpty: "Leere Sitzungen konnten nicht gelöscht werden", + selectSession: "Sitzung auswählen", + selectAllOnPage: "Alle auf dieser Seite auswählen", + clearSelection: "Auswahl aufheben", + selectedCount: "{count} ausgewählt", + deleteSelected: "{count} löschen", + deleteSelectedConfirmTitle: "{count} Sitzungen löschen?", + deleteSelectedConfirmMessage: + "Dies entfernt {count} ausgewählte Sitzungen und alle zugehörigen Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.", + selectedSessionsDeleted: "{count} Sitzungen gelöscht", + failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden", resumeInChat: "Im Chat fortsetzen", previousPage: "Vorherige Seite", nextPage: "Nächste Seite", @@ -211,6 +227,41 @@ export const de: Translations = { promptPlaceholder: "Was soll der Agent bei jedem Lauf tun?", schedule: "Zeitplan (Cron-Ausdruck)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Zeitplan", + scheduleModes: { + interval: "Wiederkehrendes Intervall", + daily: "Täglich", + weekly: "Wöchentlich", + monthly: "Monatlich", + once: "Einmalig", + custom: "Benutzerdefiniert (Cron-Ausdruck)", + intervalEvery: "Alle", + intervalUnit: "Einheit", + unitMinutes: "Minuten", + unitHours: "Stunden", + unitDays: "Tage", + timeOfDay: "Uhrzeit", + weekdays: "Wochentage", + weekdaysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], + dayOfMonth: "Tag des Monats", + onceAt: "Ausführen am", + customLabel: "Cron-Ausdruck", + customPlaceholder: "0 9 * * *", + customHint: + "Cron-Ausdruck mit fünf Feldern (Minute, Stunde, Tag, Monat, Wochentag).", + preview: "Wird gesendet als", + previewEmpty: "(unvollständig)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Alle {n} Min.", + everyHours: "Alle {n} Std.", + everyDays: "Alle {n} Tage", + dailyAt: "Täglich um {time}", + weeklyAt: "Wöchentlich am {days} um {time}", + monthlyAt: "Monatlich am {day} um {time}", + onceAt: "Einmal am {time}", + }, deliverTo: "Zustellen an", scheduledJobs: "Geplante Aufgaben", noJobs: "Keine Cron-Aufgaben konfiguriert. Erstelle oben eine.", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f792bf4dc..423876f3d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -141,6 +141,22 @@ export const en: Translations = { "This permanently removes the conversation and all of its messages. This cannot be undone.", sessionDeleted: "Session deleted", failedToDelete: "Failed to delete session", + deleteEmpty: "Delete empty", + deleteEmptyConfirmTitle: "Delete empty sessions?", + deleteEmptyConfirmMessage: + "This permanently removes {count} sessions that have no messages. Active and archived sessions are skipped. This cannot be undone.", + emptySessionsDeleted: "{count} empty sessions deleted", + failedToDeleteEmpty: "Failed to delete empty sessions", + selectSession: "Select session", + selectAllOnPage: "Select all on this page", + clearSelection: "Clear selection", + selectedCount: "{count} selected", + deleteSelected: "Delete {count}", + deleteSelectedConfirmTitle: "Delete {count} sessions?", + deleteSelectedConfirmMessage: + "This permanently removes {count} selected sessions and all their messages. This cannot be undone.", + selectedSessionsDeleted: "{count} sessions deleted", + failedToDeleteSelected: "Failed to delete selected sessions", resumeInChat: "Resume in Chat", previousPage: "Previous page", nextPage: "Next page", @@ -211,6 +227,41 @@ export const en: Translations = { promptPlaceholder: "What should the agent do on each run?", schedule: "Schedule (cron expression)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Schedule", + scheduleModes: { + interval: "Every interval", + daily: "Daily", + weekly: "Weekly", + monthly: "Monthly", + once: "Once", + custom: "Custom (cron expression)", + intervalEvery: "Every", + intervalUnit: "Unit", + unitMinutes: "minutes", + unitHours: "hours", + unitDays: "days", + timeOfDay: "Time of day", + weekdays: "Days of week", + weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + dayOfMonth: "Day of month", + onceAt: "Run at", + customLabel: "Cron expression", + customPlaceholder: "0 9 * * *", + customHint: + "Five-field cron expression (minute, hour, day, month, weekday).", + preview: "Sends as", + previewEmpty: "(incomplete)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Every {n} min", + everyHours: "Every {n} h", + everyDays: "Every {n} d", + dailyAt: "Daily at {time}", + weeklyAt: "Weekly on {days} at {time}", + monthlyAt: "Monthly on the {day} at {time}", + onceAt: "Once at {time}", + }, deliverTo: "Deliver to", scheduledJobs: "Scheduled Jobs", noJobs: "No cron jobs configured. Create one above.", diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 84a1501e9..421837007 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -141,6 +141,22 @@ export const es: Translations = { "Esto elimina permanentemente la conversación y todos sus mensajes. No se puede deshacer.", sessionDeleted: "Sesión eliminada", failedToDelete: "No se pudo eliminar la sesión", + deleteEmpty: "Eliminar vacías", + deleteEmptyConfirmTitle: "¿Eliminar sesiones vacías?", + deleteEmptyConfirmMessage: + "Esto elimina permanentemente {count} sesiones que no tienen mensajes. Se omiten las sesiones activas y archivadas. Esta acción no se puede deshacer.", + emptySessionsDeleted: "{count} sesiones vacías eliminadas", + failedToDeleteEmpty: "No se pudieron eliminar las sesiones vacías", + selectSession: "Seleccionar sesión", + selectAllOnPage: "Seleccionar todas en esta página", + clearSelection: "Limpiar selección", + selectedCount: "{count} seleccionadas", + deleteSelected: "Eliminar {count}", + deleteSelectedConfirmTitle: "¿Eliminar {count} sesiones?", + deleteSelectedConfirmMessage: + "Esto elimina permanentemente {count} sesiones seleccionadas y todos sus mensajes. No se puede deshacer.", + selectedSessionsDeleted: "{count} sesiones eliminadas", + failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas", resumeInChat: "Reanudar en el chat", previousPage: "Página anterior", nextPage: "Página siguiente", @@ -211,6 +227,41 @@ export const es: Translations = { promptPlaceholder: "¿Qué debe hacer el agente en cada ejecución?", schedule: "Programación (expresión cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Programación", + scheduleModes: { + interval: "Cada intervalo", + daily: "Diariamente", + weekly: "Semanalmente", + monthly: "Mensualmente", + once: "Una vez", + custom: "Personalizado (expresión cron)", + intervalEvery: "Cada", + intervalUnit: "Unidad", + unitMinutes: "minutos", + unitHours: "horas", + unitDays: "días", + timeOfDay: "Hora del día", + weekdays: "Días de la semana", + weekdaysShort: ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"], + dayOfMonth: "Día del mes", + onceAt: "Ejecutar el", + customLabel: "Expresión cron", + customPlaceholder: "0 9 * * *", + customHint: + "Expresión cron de cinco campos (minuto, hora, día, mes, día de la semana).", + preview: "Se envía como", + previewEmpty: "(incompleta)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Cada {n} min", + everyHours: "Cada {n} h", + everyDays: "Cada {n} d", + dailyAt: "Diariamente a las {time}", + weeklyAt: "Semanalmente los {days} a las {time}", + monthlyAt: "Mensualmente el {day} a las {time}", + onceAt: "Una vez el {time}", + }, deliverTo: "Entregar a", scheduledJobs: "Tareas programadas", noJobs: "No hay tareas cron configuradas. Crea una arriba.", diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts index 409c0a1e3..4887dc9c0 100644 --- a/web/src/i18n/fr.ts +++ b/web/src/i18n/fr.ts @@ -141,6 +141,22 @@ export const fr: Translations = { "Cela supprime définitivement la conversation et tous ses messages. Cette action est irréversible.", sessionDeleted: "Session supprimée", failedToDelete: "Échec de la suppression de la session", + deleteEmpty: "Supprimer les vides", + deleteEmptyConfirmTitle: "Supprimer les sessions vides ?", + deleteEmptyConfirmMessage: + "Cela supprime définitivement {count} sessions sans messages. Les sessions actives et archivées sont ignorées. Cette action est irréversible.", + emptySessionsDeleted: "{count} sessions vides supprimées", + failedToDeleteEmpty: "Échec de la suppression des sessions vides", + selectSession: "Sélectionner la session", + selectAllOnPage: "Tout sélectionner sur cette page", + clearSelection: "Effacer la sélection", + selectedCount: "{count} sélectionnée(s)", + deleteSelected: "Supprimer {count}", + deleteSelectedConfirmTitle: "Supprimer {count} sessions ?", + deleteSelectedConfirmMessage: + "Cela supprime définitivement {count} sessions sélectionnées et tous leurs messages. Cette action est irréversible.", + selectedSessionsDeleted: "{count} sessions supprimées", + failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées", resumeInChat: "Reprendre dans le chat", previousPage: "Page précédente", nextPage: "Page suivante", @@ -211,6 +227,41 @@ export const fr: Translations = { promptPlaceholder: "Que doit faire l'agent à chaque exécution ?", schedule: "Planning (expression cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Planification", + scheduleModes: { + interval: "Intervalle récurrent", + daily: "Quotidien", + weekly: "Hebdomadaire", + monthly: "Mensuel", + once: "Une fois", + custom: "Personnalisé (expression cron)", + intervalEvery: "Toutes les", + intervalUnit: "Unité", + unitMinutes: "minutes", + unitHours: "heures", + unitDays: "jours", + timeOfDay: "Heure de la journée", + weekdays: "Jours de la semaine", + weekdaysShort: ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"], + dayOfMonth: "Jour du mois", + onceAt: "Exécuter le", + customLabel: "Expression cron", + customPlaceholder: "0 9 * * *", + customHint: + "Expression cron à cinq champs (minute, heure, jour, mois, jour de la semaine).", + preview: "Envoyé sous la forme", + previewEmpty: "(incomplet)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Toutes les {n} min", + everyHours: "Toutes les {n} h", + everyDays: "Tous les {n} j", + dailyAt: "Tous les jours à {time}", + weeklyAt: "Chaque {days} à {time}", + monthlyAt: "Le {day} de chaque mois à {time}", + onceAt: "Une fois le {time}", + }, deliverTo: "Livrer à", scheduledJobs: "Tâches planifiées", noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.", diff --git a/web/src/i18n/ga.ts b/web/src/i18n/ga.ts index a4d41e303..6f71635b3 100644 --- a/web/src/i18n/ga.ts +++ b/web/src/i18n/ga.ts @@ -141,6 +141,22 @@ export const ga: Translations = { "Baineann sé seo an comhrá agus a chuid teachtaireachtaí ar fad go buan. Ní féidir é seo a chealú.", sessionDeleted: "Seisiún scriosta", failedToDelete: "Theip ar scriosadh an tseisiúin", + deleteEmpty: "Scrios folamh", + deleteEmptyConfirmTitle: "Scrios seisiúin fholmha?", + deleteEmptyConfirmMessage: + "Baintear {count} seisiúin gan teachtaireachtaí ar bhealach buan. Ní scriostar seisiúin ghníomhacha agus seisiúin chartlainne. Ní féidir é seo a chealú.", + emptySessionsDeleted: "{count} seisiúin fholmha scriosta", + failedToDeleteEmpty: "Theip ar scriosadh na seisiún folmha", + selectSession: "Roghnaigh seisiún", + selectAllOnPage: "Roghnaigh gach ceann ar an leathanach seo", + clearSelection: "Glan an rogha", + selectedCount: "{count} roghnaithe", + deleteSelected: "Scrios {count}", + deleteSelectedConfirmTitle: "Scrios {count} seisiún?", + deleteSelectedConfirmMessage: + "Bainfear {count} seisiún roghnaithe agus a dteachtaireachtaí go léir go buan. Ní féidir é seo a chur ar ais.", + selectedSessionsDeleted: "Scriosadh {count} seisiún", + failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe", resumeInChat: "Lean ar aghaidh sa chomhrá", previousPage: "Leathanach roimhe seo", nextPage: "An chéad leathanach eile", @@ -211,6 +227,49 @@ export const ga: Translations = { promptPlaceholder: "Cad ba chóir don agent a dhéanamh ag gach rith?", schedule: "Sceideal (slonn cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Sceideal", + scheduleModes: { + interval: "Eatramh athfhillteach", + daily: "Go laethúil", + weekly: "Go seachtainiúil", + monthly: "Go míosúil", + once: "Uair amháin", + custom: "Saincheaptha (slonn cron)", + intervalEvery: "Gach", + intervalUnit: "Aonad", + unitMinutes: "nóiméad", + unitHours: "uair", + unitDays: "lá", + timeOfDay: "Am an lae", + weekdays: "Laethanta na seachtaine", + weekdaysShort: [ + "Domh", + "Luan", + "Máirt", + "Céad", + "Déar", + "Aoine", + "Sath", + ], + dayOfMonth: "Lá den mhí", + onceAt: "Rith ag", + customLabel: "Slonn cron", + customPlaceholder: "0 9 * * *", + customHint: + "Slonn cron cúig réimse (nóiméad, uair, lá, mí, lá den tseachtain).", + preview: "Seoltar mar", + previewEmpty: "(neamhiomlán)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Gach {n} nóim", + everyHours: "Gach {n} u", + everyDays: "Gach {n} lá", + dailyAt: "Go laethúil ag {time}", + weeklyAt: "Gach {days} ag {time}", + monthlyAt: "An {day} de gach mí ag {time}", + onceAt: "Uair amháin ag {time}", + }, deliverTo: "Seachadadh chuig", scheduledJobs: "Poist sceidealta", noJobs: "Níl poist cron cumraithe. Cruthaigh ceann thuas.", diff --git a/web/src/i18n/hu.ts b/web/src/i18n/hu.ts index 7814aff86..a41382074 100644 --- a/web/src/i18n/hu.ts +++ b/web/src/i18n/hu.ts @@ -141,6 +141,22 @@ export const hu: Translations = { "Ez véglegesen eltávolítja a beszélgetést és minden üzenetét. A művelet nem vonható vissza.", sessionDeleted: "Munkamenet törölve", failedToDelete: "Nem sikerült törölni a munkamenetet", + deleteEmpty: "Üresek törlése", + deleteEmptyConfirmTitle: "Üres munkamenetek törlése?", + deleteEmptyConfirmMessage: + "Ez véglegesen eltávolít {count} olyan munkamenetet, amely nem tartalmaz üzenetet. Az aktív és archivált munkameneteket kihagyja. Ez nem vonható vissza.", + emptySessionsDeleted: "{count} üres munkamenet törölve", + failedToDeleteEmpty: "Nem sikerült törölni az üres munkameneteket", + selectSession: "Munkamenet kijelölése", + selectAllOnPage: "Az oldalon mindegyik kijelölése", + clearSelection: "Kijelölés törlése", + selectedCount: "{count} kijelölve", + deleteSelected: "{count} törlése", + deleteSelectedConfirmTitle: "{count} munkamenet törlése?", + deleteSelectedConfirmMessage: + "Ez véglegesen eltávolítja a kijelölt {count} munkamenetet és minden üzenetüket. A művelet nem vonható vissza.", + selectedSessionsDeleted: "{count} munkamenet törölve", + failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket", resumeInChat: "Folytatás a csevegésben", previousPage: "Előző oldal", nextPage: "Következő oldal", @@ -211,6 +227,41 @@ export const hu: Translations = { promptPlaceholder: "Mit tegyen az ügynök minden futtatáskor?", schedule: "Ütemezés (cron-kifejezés)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Ütemezés", + scheduleModes: { + interval: "Ismétlődő intervallum", + daily: "Naponta", + weekly: "Hetente", + monthly: "Havonta", + once: "Egyszer", + custom: "Egyéni (cron kifejezés)", + intervalEvery: "Minden", + intervalUnit: "Egység", + unitMinutes: "perc", + unitHours: "óra", + unitDays: "nap", + timeOfDay: "Napszak", + weekdays: "Hét napjai", + weekdaysShort: ["V", "H", "K", "Sze", "Cs", "P", "Szo"], + dayOfMonth: "Hónap napja", + onceAt: "Futtatás ekkor", + customLabel: "Cron kifejezés", + customPlaceholder: "0 9 * * *", + customHint: + "Öt mezős cron kifejezés (perc, óra, nap, hónap, hét napja).", + preview: "Elküldve mint", + previewEmpty: "(hiányos)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "{n} percenként", + everyHours: "{n} óránként", + everyDays: "{n} naponta", + dailyAt: "Naponta {time}-kor", + weeklyAt: "Hetente {days} {time}-kor", + monthlyAt: "Havonta {day} {time}-kor", + onceAt: "Egyszer {time}-kor", + }, deliverTo: "Kézbesítés ide", scheduledJobs: "Ütemezett feladatok", noJobs: "Nincs beállított cron-feladat. Hozzon létre egyet fent.", diff --git a/web/src/i18n/it.ts b/web/src/i18n/it.ts index 1485cb687..61ca8b7bb 100644 --- a/web/src/i18n/it.ts +++ b/web/src/i18n/it.ts @@ -141,6 +141,22 @@ export const it: Translations = { "Questa operazione rimuove definitivamente la conversazione e tutti i suoi messaggi. Non può essere annullata.", sessionDeleted: "Sessione eliminata", failedToDelete: "Eliminazione della sessione non riuscita", + deleteEmpty: "Elimina vuote", + deleteEmptyConfirmTitle: "Eliminare le sessioni vuote?", + deleteEmptyConfirmMessage: + "Questa azione rimuove in modo permanente {count} sessioni senza messaggi. Le sessioni attive e archiviate vengono ignorate. L'azione non può essere annullata.", + emptySessionsDeleted: "{count} sessioni vuote eliminate", + failedToDeleteEmpty: "Impossibile eliminare le sessioni vuote", + selectSession: "Seleziona sessione", + selectAllOnPage: "Seleziona tutte in questa pagina", + clearSelection: "Annulla selezione", + selectedCount: "{count} selezionate", + deleteSelected: "Elimina {count}", + deleteSelectedConfirmTitle: "Eliminare {count} sessioni?", + deleteSelectedConfirmMessage: + "Verranno eliminate definitivamente {count} sessioni selezionate e tutti i loro messaggi. L'operazione non può essere annullata.", + selectedSessionsDeleted: "{count} sessioni eliminate", + failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate", resumeInChat: "Riprendi nella chat", previousPage: "Pagina precedente", nextPage: "Pagina successiva", @@ -211,6 +227,41 @@ export const it: Translations = { promptPlaceholder: "Cosa deve fare l'agente a ogni esecuzione?", schedule: "Pianificazione (espressione cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Pianificazione", + scheduleModes: { + interval: "Intervallo ricorrente", + daily: "Giornaliero", + weekly: "Settimanale", + monthly: "Mensile", + once: "Una volta", + custom: "Personalizzato (espressione cron)", + intervalEvery: "Ogni", + intervalUnit: "Unità", + unitMinutes: "minuti", + unitHours: "ore", + unitDays: "giorni", + timeOfDay: "Ora del giorno", + weekdays: "Giorni della settimana", + weekdaysShort: ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"], + dayOfMonth: "Giorno del mese", + onceAt: "Esegui il", + customLabel: "Espressione cron", + customPlaceholder: "0 9 * * *", + customHint: + "Espressione cron a cinque campi (minuto, ora, giorno, mese, giorno della settimana).", + preview: "Inviato come", + previewEmpty: "(incompleta)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Ogni {n} min", + everyHours: "Ogni {n} h", + everyDays: "Ogni {n} g", + dailyAt: "Tutti i giorni alle {time}", + weeklyAt: "Ogni {days} alle {time}", + monthlyAt: "Il {day} di ogni mese alle {time}", + onceAt: "Una volta il {time}", + }, deliverTo: "Consegna a", scheduledJobs: "Attività pianificate", noJobs: "Nessuna attività cron configurata. Creane una sopra.", diff --git a/web/src/i18n/ja.ts b/web/src/i18n/ja.ts index 1b9ad88ea..e3db6b9a2 100644 --- a/web/src/i18n/ja.ts +++ b/web/src/i18n/ja.ts @@ -141,6 +141,22 @@ export const ja: Translations = { "会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。", sessionDeleted: "セッションを削除しました", failedToDelete: "セッションの削除に失敗しました", + deleteEmpty: "空を削除", + deleteEmptyConfirmTitle: "空のセッションを削除しますか?", + deleteEmptyConfirmMessage: + "メッセージのない {count} 件のセッションを完全に削除します。アクティブおよびアーカイブされたセッションはスキップされます。この操作は元に戻せません。", + emptySessionsDeleted: "{count} 件の空のセッションを削除しました", + failedToDeleteEmpty: "空のセッションの削除に失敗しました", + selectSession: "セッションを選択", + selectAllOnPage: "このページの全てを選択", + clearSelection: "選択を解除", + selectedCount: "{count}件選択中", + deleteSelected: "{count}件削除", + deleteSelectedConfirmTitle: "{count}件のセッションを削除しますか?", + deleteSelectedConfirmMessage: + "選択した{count}件のセッションとそのすべてのメッセージが完全に削除されます。この操作は取り消せません。", + selectedSessionsDeleted: "{count}件のセッションを削除しました", + failedToDeleteSelected: "選択したセッションの削除に失敗しました", resumeInChat: "チャットで再開", previousPage: "前のページ", nextPage: "次のページ", @@ -211,6 +227,40 @@ export const ja: Translations = { promptPlaceholder: "実行ごとにエージェントが行う内容は?", schedule: "スケジュール (cron 式)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "スケジュール", + scheduleModes: { + interval: "繰り返し間隔", + daily: "毎日", + weekly: "毎週", + monthly: "毎月", + once: "1回のみ", + custom: "カスタム(cron式)", + intervalEvery: "実行間隔", + intervalUnit: "単位", + unitMinutes: "分", + unitHours: "時間", + unitDays: "日", + timeOfDay: "時刻", + weekdays: "曜日", + weekdaysShort: ["日", "月", "火", "水", "木", "金", "土"], + dayOfMonth: "日付", + onceAt: "実行日時", + customLabel: "cron式", + customPlaceholder: "0 9 * * *", + customHint: "5フィールドのcron式(分、時、日、月、曜日)。", + preview: "送信形式", + previewEmpty: "(未入力)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "{n}分ごと", + everyHours: "{n}時間ごと", + everyDays: "{n}日ごと", + dailyAt: "毎日 {time}", + weeklyAt: "毎週 {days} {time}", + monthlyAt: "毎月{day} {time}", + onceAt: "{time} に1回", + }, deliverTo: "配信先", scheduledJobs: "スケジュール済みジョブ", noJobs: "Cron ジョブが設定されていません。上で作成してください。", diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts index 4fcb6f001..b624938f8 100644 --- a/web/src/i18n/ko.ts +++ b/web/src/i18n/ko.ts @@ -141,6 +141,22 @@ export const ko: Translations = { "이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.", sessionDeleted: "세션이 삭제되었습니다", failedToDelete: "세션 삭제에 실패했습니다", + deleteEmpty: "빈 세션 삭제", + deleteEmptyConfirmTitle: "빈 세션을 삭제하시겠습니까?", + deleteEmptyConfirmMessage: + "메시지가 없는 {count}개의 세션을 영구적으로 삭제합니다. 활성 및 보관된 세션은 건너뜁니다. 이 작업은 되돌릴 수 없습니다.", + emptySessionsDeleted: "빈 세션 {count}개 삭제됨", + failedToDeleteEmpty: "빈 세션 삭제에 실패했습니다", + selectSession: "세션 선택", + selectAllOnPage: "이 페이지 전체 선택", + clearSelection: "선택 해제", + selectedCount: "{count}개 선택됨", + deleteSelected: "{count}개 삭제", + deleteSelectedConfirmTitle: "{count}개 세션을 삭제하시겠습니까?", + deleteSelectedConfirmMessage: + "선택한 {count}개 세션과 모든 메시지가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.", + selectedSessionsDeleted: "{count}개 세션이 삭제되었습니다", + failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다", resumeInChat: "채팅에서 다시 시작", previousPage: "이전 페이지", nextPage: "다음 페이지", @@ -211,6 +227,40 @@ export const ko: Translations = { promptPlaceholder: "에이전트가 매 실행 시 무엇을 해야 합니까?", schedule: "스케줄 (cron 표현식)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "일정", + scheduleModes: { + interval: "반복 간격", + daily: "매일", + weekly: "매주", + monthly: "매월", + once: "한 번", + custom: "사용자 지정 (cron 표현식)", + intervalEvery: "실행 간격", + intervalUnit: "단위", + unitMinutes: "분", + unitHours: "시간", + unitDays: "일", + timeOfDay: "시각", + weekdays: "요일", + weekdaysShort: ["일", "월", "화", "수", "목", "금", "토"], + dayOfMonth: "날짜", + onceAt: "실행 시각", + customLabel: "cron 표현식", + customPlaceholder: "0 9 * * *", + customHint: "5개 필드의 cron 표현식 (분, 시, 일, 월, 요일).", + preview: "전송 형식", + previewEmpty: "(미완성)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "{n}분마다", + everyHours: "{n}시간마다", + everyDays: "{n}일마다", + dailyAt: "매일 {time}", + weeklyAt: "매주 {days} {time}", + monthlyAt: "매월 {day} {time}", + onceAt: "{time}에 한 번", + }, deliverTo: "전달 대상", scheduledJobs: "예약된 작업", noJobs: "구성된 cron 작업이 없습니다. 위에서 하나 만드세요.", diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts index b84c99b67..109027bc7 100644 --- a/web/src/i18n/pt.ts +++ b/web/src/i18n/pt.ts @@ -141,6 +141,22 @@ export const pt: Translations = { "Esta ação remove permanentemente a conversa e todas as suas mensagens. Não é possível anular.", sessionDeleted: "Sessão eliminada", failedToDelete: "Falha ao eliminar a sessão", + deleteEmpty: "Eliminar vazias", + deleteEmptyConfirmTitle: "Eliminar sessões vazias?", + deleteEmptyConfirmMessage: + "Isto remove permanentemente {count} sessões sem mensagens. As sessões ativas e arquivadas são ignoradas. Esta ação não pode ser desfeita.", + emptySessionsDeleted: "{count} sessões vazias eliminadas", + failedToDeleteEmpty: "Falha ao eliminar sessões vazias", + selectSession: "Selecionar sessão", + selectAllOnPage: "Selecionar todas nesta página", + clearSelection: "Limpar seleção", + selectedCount: "{count} selecionadas", + deleteSelected: "Eliminar {count}", + deleteSelectedConfirmTitle: "Eliminar {count} sessões?", + deleteSelectedConfirmMessage: + "Isto remove permanentemente {count} sessões selecionadas e todas as suas mensagens. Não pode ser desfeito.", + selectedSessionsDeleted: "{count} sessões eliminadas", + failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas", resumeInChat: "Retomar no Chat", previousPage: "Página anterior", nextPage: "Página seguinte", @@ -211,6 +227,41 @@ export const pt: Translations = { promptPlaceholder: "O que deve o agente fazer em cada execução?", schedule: "Agendamento (expressão cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Agendamento", + scheduleModes: { + interval: "Intervalo recorrente", + daily: "Diariamente", + weekly: "Semanalmente", + monthly: "Mensalmente", + once: "Uma vez", + custom: "Personalizado (expressão cron)", + intervalEvery: "A cada", + intervalUnit: "Unidade", + unitMinutes: "minutos", + unitHours: "horas", + unitDays: "dias", + timeOfDay: "Hora do dia", + weekdays: "Dias da semana", + weekdaysShort: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"], + dayOfMonth: "Dia do mês", + onceAt: "Executar em", + customLabel: "Expressão cron", + customPlaceholder: "0 9 * * *", + customHint: + "Expressão cron de cinco campos (minuto, hora, dia, mês, dia da semana).", + preview: "Enviado como", + previewEmpty: "(incompleta)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "A cada {n} min", + everyHours: "A cada {n} h", + everyDays: "A cada {n} d", + dailyAt: "Diariamente às {time}", + weeklyAt: "Semanalmente {days} às {time}", + monthlyAt: "Mensalmente no dia {day} às {time}", + onceAt: "Uma vez em {time}", + }, deliverTo: "Entregar a", scheduledJobs: "Tarefas agendadas", noJobs: "Sem tarefas cron configuradas. Crie uma acima.", diff --git a/web/src/i18n/ru.ts b/web/src/i18n/ru.ts index e9b5e2cb8..51eaf774c 100644 --- a/web/src/i18n/ru.ts +++ b/web/src/i18n/ru.ts @@ -141,6 +141,22 @@ export const ru: Translations = { "Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.", sessionDeleted: "Сессия удалена", failedToDelete: "Не удалось удалить сессию", + deleteEmpty: "Удалить пустые", + deleteEmptyConfirmTitle: "Удалить пустые сессии?", + deleteEmptyConfirmMessage: + "Это безвозвратно удалит {count} сессий без сообщений. Активные и архивные сессии будут пропущены. Это действие нельзя отменить.", + emptySessionsDeleted: "Удалено пустых сессий: {count}", + failedToDeleteEmpty: "Не удалось удалить пустые сессии", + selectSession: "Выбрать сессию", + selectAllOnPage: "Выбрать все на этой странице", + clearSelection: "Снять выделение", + selectedCount: "Выбрано: {count}", + deleteSelected: "Удалить {count}", + deleteSelectedConfirmTitle: "Удалить {count} сессий?", + deleteSelectedConfirmMessage: + "Это безвозвратно удалит {count} выбранных сессий и все их сообщения. Это действие нельзя отменить.", + selectedSessionsDeleted: "Удалено сессий: {count}", + failedToDeleteSelected: "Не удалось удалить выбранные сессии", resumeInChat: "Продолжить в чате", previousPage: "Предыдущая страница", nextPage: "Следующая страница", @@ -211,6 +227,41 @@ export const ru: Translations = { promptPlaceholder: "Что должен делать агент при каждом запуске?", schedule: "Расписание (cron-выражение)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Расписание", + scheduleModes: { + interval: "Повторяющийся интервал", + daily: "Ежедневно", + weekly: "Еженедельно", + monthly: "Ежемесячно", + once: "Один раз", + custom: "Произвольное (cron-выражение)", + intervalEvery: "Каждые", + intervalUnit: "Единицы", + unitMinutes: "минут", + unitHours: "часов", + unitDays: "дней", + timeOfDay: "Время суток", + weekdays: "Дни недели", + weekdaysShort: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"], + dayOfMonth: "День месяца", + onceAt: "Выполнить в", + customLabel: "Cron-выражение", + customPlaceholder: "0 9 * * *", + customHint: + "Cron-выражение из пяти полей (минута, час, день, месяц, день недели).", + preview: "Отправляется как", + previewEmpty: "(не заполнено)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Каждые {n} мин", + everyHours: "Каждые {n} ч", + everyDays: "Каждые {n} дн", + dailyAt: "Ежедневно в {time}", + weeklyAt: "Еженедельно в {days} в {time}", + monthlyAt: "Ежемесячно {day} числа в {time}", + onceAt: "Один раз {time}", + }, deliverTo: "Доставить в", scheduledJobs: "Запланированные задачи", noJobs: "Cron-задачи не настроены. Создайте задачу выше.", diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts index f9aaa14d4..85910c88e 100644 --- a/web/src/i18n/tr.ts +++ b/web/src/i18n/tr.ts @@ -141,6 +141,22 @@ export const tr: Translations = { "Bu, konuşmayı ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.", sessionDeleted: "Oturum silindi", failedToDelete: "Oturum silinemedi", + deleteEmpty: "Boşları sil", + deleteEmptyConfirmTitle: "Boş oturumlar silinsin mi?", + deleteEmptyConfirmMessage: + "Bu işlem, mesaj içermeyen {count} oturumu kalıcı olarak siler. Aktif ve arşivlenmiş oturumlar atlanır. Bu işlem geri alınamaz.", + emptySessionsDeleted: "{count} boş oturum silindi", + failedToDeleteEmpty: "Boş oturumlar silinemedi", + selectSession: "Oturumu seç", + selectAllOnPage: "Bu sayfadakilerin tümünü seç", + clearSelection: "Seçimi temizle", + selectedCount: "{count} seçildi", + deleteSelected: "{count} sil", + deleteSelectedConfirmTitle: "{count} oturum silinsin mi?", + deleteSelectedConfirmMessage: + "Bu, seçilen {count} oturumu ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.", + selectedSessionsDeleted: "{count} oturum silindi", + failedToDeleteSelected: "Seçilen oturumlar silinemedi", resumeInChat: "Sohbette Devam Et", previousPage: "Önceki sayfa", nextPage: "Sonraki sayfa", @@ -211,6 +227,41 @@ export const tr: Translations = { promptPlaceholder: "Agent her çalıştırmada ne yapmalı?", schedule: "Zamanlama (cron ifadesi)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Zamanlama", + scheduleModes: { + interval: "Tekrarlanan aralık", + daily: "Günlük", + weekly: "Haftalık", + monthly: "Aylık", + once: "Bir kez", + custom: "Özel (cron ifadesi)", + intervalEvery: "Her", + intervalUnit: "Birim", + unitMinutes: "dakika", + unitHours: "saat", + unitDays: "gün", + timeOfDay: "Günün saati", + weekdays: "Haftanın günleri", + weekdaysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"], + dayOfMonth: "Ayın günü", + onceAt: "Çalıştırma zamanı", + customLabel: "Cron ifadesi", + customPlaceholder: "0 9 * * *", + customHint: + "Beş alanlı cron ifadesi (dakika, saat, gün, ay, haftanın günü).", + preview: "Gönderilecek olan", + previewEmpty: "(eksik)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Her {n} dk", + everyHours: "Her {n} sa", + everyDays: "Her {n} gün", + dailyAt: "Her gün {time}", + weeklyAt: "Her hafta {days} {time}", + monthlyAt: "Her ayın {day} günü {time}", + onceAt: "{time} bir kez", + }, deliverTo: "Şuraya teslim et", scheduledJobs: "Zamanlanmış Görevler", noJobs: "Yapılandırılmış cron görevi yok. Yukarıdan bir tane oluşturun.", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 15f2f1a0c..a35fcb898 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -158,6 +158,20 @@ export interface Translations { confirmDeleteMessage: string; sessionDeleted: string; failedToDelete: string; + deleteEmpty: string; + deleteEmptyConfirmTitle: string; + deleteEmptyConfirmMessage: string; + emptySessionsDeleted: string; + failedToDeleteEmpty: string; + selectSession: string; + selectAllOnPage: string; + clearSelection: string; + selectedCount: string; + deleteSelected: string; + deleteSelectedConfirmTitle: string; + deleteSelectedConfirmMessage: string; + selectedSessionsDeleted: string; + failedToDeleteSelected: string; resumeInChat: string; previousPage: string; nextPage: string; @@ -231,6 +245,40 @@ export interface Translations { promptPlaceholder: string; schedule: string; schedulePlaceholder: string; + scheduleMode: string; + scheduleModes: { + interval: string; + daily: string; + weekly: string; + monthly: string; + once: string; + custom: string; + intervalEvery: string; + intervalUnit: string; + unitMinutes: string; + unitHours: string; + unitDays: string; + timeOfDay: string; + weekdays: string; + weekdaysShort: [string, string, string, string, string, string, string]; + dayOfMonth: string; + onceAt: string; + customLabel: string; + customPlaceholder: string; + customHint: string; + preview: string; + previewEmpty: string; + }; + scheduleDescribe: { + none: string; + everyMinutes: string; + everyHours: string; + everyDays: string; + dailyAt: string; + weeklyAt: string; + monthlyAt: string; + onceAt: string; + }; deliverTo: string; scheduledJobs: string; noJobs: string; diff --git a/web/src/i18n/uk.ts b/web/src/i18n/uk.ts index 8d67f58ec..ce1a4babf 100644 --- a/web/src/i18n/uk.ts +++ b/web/src/i18n/uk.ts @@ -141,6 +141,22 @@ export const uk: Translations = { "Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.", sessionDeleted: "Сесію видалено", failedToDelete: "Не вдалося видалити сесію", + deleteEmpty: "Видалити порожні", + deleteEmptyConfirmTitle: "Видалити порожні сесії?", + deleteEmptyConfirmMessage: + "Це остаточно видалить {count} сесій без повідомлень. Активні та архівні сесії пропускаються. Цю дію неможливо скасувати.", + emptySessionsDeleted: "Видалено порожніх сесій: {count}", + failedToDeleteEmpty: "Не вдалося видалити порожні сесії", + selectSession: "Вибрати сесію", + selectAllOnPage: "Вибрати всі на цій сторінці", + clearSelection: "Скинути вибір", + selectedCount: "Вибрано: {count}", + deleteSelected: "Видалити {count}", + deleteSelectedConfirmTitle: "Видалити {count} сесій?", + deleteSelectedConfirmMessage: + "Це назавжди видалить {count} вибраних сесій і всі їхні повідомлення. Цю дію неможливо скасувати.", + selectedSessionsDeleted: "Видалено сесій: {count}", + failedToDeleteSelected: "Не вдалося видалити вибрані сесії", resumeInChat: "Продовжити в чаті", previousPage: "Попередня сторінка", nextPage: "Наступна сторінка", @@ -211,6 +227,41 @@ export const uk: Translations = { promptPlaceholder: "Що агент має робити при кожному запуску?", schedule: "Розклад (cron-вираз)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Розклад", + scheduleModes: { + interval: "Повторюваний інтервал", + daily: "Щодня", + weekly: "Щотижня", + monthly: "Щомісяця", + once: "Один раз", + custom: "Користувацьке (cron-вираз)", + intervalEvery: "Кожні", + intervalUnit: "Одиниці", + unitMinutes: "хвилин", + unitHours: "годин", + unitDays: "днів", + timeOfDay: "Час доби", + weekdays: "Дні тижня", + weekdaysShort: ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"], + dayOfMonth: "День місяця", + onceAt: "Виконати о", + customLabel: "Cron-вираз", + customPlaceholder: "0 9 * * *", + customHint: + "П'ятиполевий cron-вираз (хвилина, година, день, місяць, день тижня).", + preview: "Надсилається як", + previewEmpty: "(не заповнено)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Кожні {n} хв", + everyHours: "Кожні {n} год", + everyDays: "Кожні {n} дн", + dailyAt: "Щодня о {time}", + weeklyAt: "Щотижня у {days} о {time}", + monthlyAt: "Щомісяця {day} числа о {time}", + onceAt: "Один раз {time}", + }, deliverTo: "Надіслати на", scheduledJobs: "Заплановані завдання", noJobs: "Cron-завдань не налаштовано. Створіть одне вище.", diff --git a/web/src/i18n/zh-hant.ts b/web/src/i18n/zh-hant.ts index e569b27a4..e2c4ff725 100644 --- a/web/src/i18n/zh-hant.ts +++ b/web/src/i18n/zh-hant.ts @@ -141,6 +141,22 @@ export const zhHant: Translations = { "此操作將永久移除對話及其所有訊息,無法復原。", sessionDeleted: "工作階段已刪除", failedToDelete: "刪除工作階段失敗", + deleteEmpty: "刪除空工作階段", + deleteEmptyConfirmTitle: "刪除空工作階段?", + deleteEmptyConfirmMessage: + "這將永久刪除 {count} 個沒有訊息的工作階段。活動中與已封存的工作階段將被略過。此動作無法復原。", + emptySessionsDeleted: "已刪除 {count} 個空工作階段", + failedToDeleteEmpty: "刪除空工作階段失敗", + selectSession: "選擇工作階段", + selectAllOnPage: "全選本頁", + clearSelection: "清除選擇", + selectedCount: "已選擇 {count} 個", + deleteSelected: "刪除 {count} 個", + deleteSelectedConfirmTitle: "刪除 {count} 個工作階段?", + deleteSelectedConfirmMessage: + "此操作將永久刪除所選的 {count} 個工作階段及其所有訊息。無法復原。", + selectedSessionsDeleted: "已刪除 {count} 個工作階段", + failedToDeleteSelected: "刪除所選工作階段失敗", resumeInChat: "在對話中繼續", previousPage: "上一頁", nextPage: "下一頁", @@ -211,6 +227,40 @@ export const zhHant: Translations = { promptPlaceholder: "代理每次執行時應做什麼?", schedule: "排程(cron 運算式)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "排程", + scheduleModes: { + interval: "重複間隔", + daily: "每日", + weekly: "每週", + monthly: "每月", + once: "僅一次", + custom: "自訂(cron 運算式)", + intervalEvery: "每", + intervalUnit: "單位", + unitMinutes: "分鐘", + unitHours: "小時", + unitDays: "天", + timeOfDay: "時間", + weekdays: "星期", + weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"], + dayOfMonth: "日期", + onceAt: "執行時間", + customLabel: "cron 運算式", + customPlaceholder: "0 9 * * *", + customHint: "五欄位 cron 運算式(分、時、日、月、星期)。", + preview: "傳送為", + previewEmpty: "(未完成)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "每 {n} 分鐘", + everyHours: "每 {n} 小時", + everyDays: "每 {n} 天", + dailyAt: "每天 {time}", + weeklyAt: "每週 {days} {time}", + monthlyAt: "每月{day} {time}", + onceAt: "{time} 執行一次", + }, deliverTo: "傳送至", scheduledJobs: "已排程任務", noJobs: "尚未設定排程任務。請於上方建立。", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 5bc5ae493..d60dea816 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -139,6 +139,22 @@ export const zh: Translations = { confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。", sessionDeleted: "会话已删除", failedToDelete: "删除会话失败", + deleteEmpty: "删除空会话", + deleteEmptyConfirmTitle: "删除空会话?", + deleteEmptyConfirmMessage: + "这将永久删除 {count} 个没有消息的会话。活动和已归档的会话将被跳过。此操作无法撤销。", + emptySessionsDeleted: "已删除 {count} 个空会话", + failedToDeleteEmpty: "删除空会话失败", + selectSession: "选择会话", + selectAllOnPage: "全选本页", + clearSelection: "清除选择", + selectedCount: "已选择 {count} 个", + deleteSelected: "删除 {count} 个", + deleteSelectedConfirmTitle: "删除 {count} 个会话?", + deleteSelectedConfirmMessage: + "此操作将永久删除所选的 {count} 个会话及其所有消息。无法撤销。", + selectedSessionsDeleted: "已删除 {count} 个会话", + failedToDeleteSelected: "删除所选会话失败", resumeInChat: "在对话中继续", previousPage: "上一页", nextPage: "下一页", @@ -208,6 +224,40 @@ export const zh: Translations = { promptPlaceholder: "代理每次运行时应执行什么操作?", schedule: "调度表达式(cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "调度", + scheduleModes: { + interval: "重复间隔", + daily: "每天", + weekly: "每周", + monthly: "每月", + once: "仅一次", + custom: "自定义(cron 表达式)", + intervalEvery: "每", + intervalUnit: "单位", + unitMinutes: "分钟", + unitHours: "小时", + unitDays: "天", + timeOfDay: "时间", + weekdays: "星期", + weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"], + dayOfMonth: "日期", + onceAt: "执行时间", + customLabel: "cron 表达式", + customPlaceholder: "0 9 * * *", + customHint: "五字段 cron 表达式(分、时、日、月、星期)。", + preview: "发送为", + previewEmpty: "(未完成)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "每 {n} 分钟", + everyHours: "每 {n} 小时", + everyDays: "每 {n} 天", + dailyAt: "每天 {time}", + weeklyAt: "每周 {days} {time}", + monthlyAt: "每月{day} {time}", + onceAt: "{time} 执行一次", + }, deliverTo: "投递至", scheduledJobs: "已调度任务", noJobs: "暂无定时任务。在上方创建一个。", diff --git a/web/src/index.css b/web/src/index.css index 342a4856f..1bbb9c4dd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -83,6 +83,15 @@ --theme-radius: 0.5rem; --theme-spacing-mul: 1; --theme-density: comfortable; + + /* Data-series accents — consumed by Analytics + Models pages for the + input-vs-output token visualisations (chart bars, table values, + legend swatches). Defaults are tuned for the Hermes-teal LENS_0 + look: cream input + emerald-400 output read as warm/cool against + the dark canvas. Themes override via ThemeProvider, which emits + these as `--series-input-token` / `--series-output-token`. */ + --series-input-token: #ffe6cb; + --series-output-token: #34d399; } /* Theme tokens cascade into the document root so every descendant inherits diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6202b9f28..f22a39613 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -238,6 +238,18 @@ export const api = { fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", }), + getEmptySessionsCount: () => + fetchJSON<{ count: number }>("/api/sessions/empty/count"), + deleteEmptySessions: () => + fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", { + method: "DELETE", + }), + bulkDeleteSessions: (ids: string[]) => + fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }), renameSession: (id: string, title: string) => fetchJSON<{ ok: boolean; title: string }>( `/api/sessions/${encodeURIComponent(id)}`, diff --git a/web/src/lib/schedule.ts b/web/src/lib/schedule.ts new file mode 100644 index 000000000..d36fa52c2 --- /dev/null +++ b/web/src/lib/schedule.ts @@ -0,0 +1,382 @@ +/** + * Schedule builder helpers for the cron page. + * + * The hermes-agent backend (cron/jobs.py::parse_schedule) accepts a + * surprisingly broad set of string formats: + * + * - Duration (one-shot): "30m", "2h", "1d" + * - Interval (recurring): "every 30m", "every 2h", "every 1d" + * - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5" + * - ISO timestamp (one-shot): "2026-02-03T14:00:00" + * + * Power users can hand-type any of those, but for everyone else the + * dashboard now offers a human-readable picker. This module is the + * pure logic layer behind that picker: + * + * - {@link buildScheduleString} turns the picker's structured state + * into one of the strings above. + * - {@link describeSchedule} goes the other way: takes the structured + * schedule shape the API returns (``CronJob.schedule``) and produces + * a human-readable sentence for the job list. It recognises common + * cron-expression shapes (daily/weekly/monthly) so users don't have + * to parse "30 14 * * 1,3,5" by eye. + * + * Kept dependency-free and locale-string-driven so it tree-shakes + * cleanly and is testable in isolation if we ever wire up vitest here. + */ + +/** Picker modes — each renders a different set of inputs in the UI but + * all funnel through {@link buildScheduleString} to a backend-compatible + * string. ``custom`` is the escape hatch for power users who still want + * to type a raw cron expression. */ +export type ScheduleMode = + | "interval" + | "daily" + | "weekly" + | "monthly" + | "once" + | "custom"; + +/** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */ +export type IntervalUnit = "minutes" | "hours" | "days"; + +/** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what + * croniter expects on the backend (no need to remap on submit). */ +export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const; +export type Weekday = (typeof WEEKDAY_INDEXES)[number]; + +export interface ScheduleBuilderState { + /** Index of which "custom" radio is selected. */ + mode: ScheduleMode; + + /** Interval mode: positive integer, paired with ``intervalUnit``. */ + intervalValue: number; + intervalUnit: IntervalUnit; + + /** Daily/weekly/monthly mode: "HH:MM" 24h format from . */ + timeOfDay: string; + + /** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is + * still valid — we send "*" for the day-of-week cron field. */ + weekdays: Weekday[]; + + /** Monthly mode: 1..31 (no support for "last day of month" sugar — the + * croniter ``L`` extension isn't enabled in the parse_schedule regex). */ + dayOfMonth: number; + + /** Once mode: ``YYYY-MM-DDTHH:MM`` from . */ + onceAt: string; + + /** Custom mode: raw user-typed cron expression. Stored separately so + * flipping between modes doesn't erase the user's work. */ + custom: string; +} + +/** Default state — "every 30 minutes" is the most-common-cron-pattern + * starting point and avoids forcing the user to pick everything from + * scratch. */ +export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = { + mode: "interval", + intervalValue: 30, + intervalUnit: "minutes", + timeOfDay: "09:00", + weekdays: [1, 2, 3, 4, 5], + dayOfMonth: 1, + onceAt: "", + custom: "", +}; + +const UNIT_SUFFIX: Record = { + minutes: "m", + hours: "h", + days: "d", +}; + +/** Build the schedule string from picker state. Returns ``""`` when the + * state is incomplete enough that the backend would 400 — the caller + * uses that to disable the Submit button. + * + * Why we lean on the broad parse_schedule grammar instead of always + * emitting cron expressions: interval syntax ("every 30m") survives a + * backend without ``croniter`` installed and renders more readably in + * the job list. We only emit raw cron when the picker truly needs the + * cron field expressiveness (specific weekdays, specific day-of-month). */ +export function buildScheduleString(state: ScheduleBuilderState): string { + switch (state.mode) { + case "interval": { + const n = Math.floor(state.intervalValue); + if (!Number.isFinite(n) || n < 1) return ""; + return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`; + } + case "daily": { + const parsed = parseTimeOfDay(state.timeOfDay); + if (!parsed) return ""; + return `${parsed.minute} ${parsed.hour} * * *`; + } + case "weekly": { + const parsed = parseTimeOfDay(state.timeOfDay); + if (!parsed) return ""; + // Empty weekday selection → "*" (every day) rather than a backend + // 400. The Daily mode is the cleaner choice for that, but if the + // user toggles all days off in Weekly mode we still emit a valid + // expression instead of breaking the submit. + const days = + state.weekdays.length === 0 + ? "*" + : [...state.weekdays].sort((a, b) => a - b).join(","); + return `${parsed.minute} ${parsed.hour} * * ${days}`; + } + case "monthly": { + const parsed = parseTimeOfDay(state.timeOfDay); + if (!parsed) return ""; + const dom = Math.floor(state.dayOfMonth); + if (!Number.isFinite(dom) || dom < 1 || dom > 31) return ""; + return `${parsed.minute} ${parsed.hour} ${dom} * *`; + } + case "once": { + const v = state.onceAt.trim(); + if (!v) return ""; + // already emits the + // "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly. + // Append ":00" so the backend's regex hits the "T" branch and + // the seconds component lines up with isoformat() output. + return v.length === 16 ? `${v}:00` : v; + } + case "custom": + return state.custom.trim(); + } +} + +function parseTimeOfDay(value: string): { hour: number; minute: number } | null { + if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null; + const [hh, mm] = value.split(":"); + const hour = parseInt(hh, 10); + const minute = parseInt(mm, 10); + if ( + !Number.isFinite(hour) || + !Number.isFinite(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return null; + } + return { hour, minute }; +} + +/** Translation surface the human-readable describer needs. Passing it + * in (instead of importing ``useI18n``) keeps the helper pure and + * testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */ +export interface ScheduleDescribeStrings { + /** Display when no schedule can be resolved (e.g. legacy/blank job). */ + none: string; + /** "Every {n} minute(s)" — caller pluralises via {n}. */ + everyMinutes: string; + everyHours: string; + everyDays: string; + /** "Daily at {time}" */ + dailyAt: string; + /** "Weekly on {days} at {time}" */ + weeklyAt: string; + /** "Monthly on the {day} at {time}" */ + monthlyAt: string; + /** "Once at {time}" */ + onceAt: string; + /** Weekday short names indexed 0..6 (Sunday-first). */ + weekdaysShort: [string, string, string, string, string, string, string]; + /** Ordinal suffix builder, e.g. "1st", "22nd". For locales that + * don't use English ordinals, just return ``String(day)``. */ + ordinal: (day: number) => string; +} + +/** Schedule shape stored on a ``CronJob`` row (see api.ts). */ +export interface ScheduleLike { + kind?: string; + expr?: string; + minutes?: number; + run_at?: string; + display?: string; +} + +/** Human-readable description of a stored schedule. + * + * Prefers a structured render over the raw ``display`` string so cron + * expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed, + * Fri at 14:30" instead of the raw five-field gibberish. Falls back to + * ``display`` / ``expr`` / ``none`` in that order if we can't make sense + * of the schedule (e.g. exotic cron with ranges, step values, or @reboot + * macros that we'd misrepresent if we tried to "humanize"). */ +export function describeSchedule( + schedule: ScheduleLike | undefined, + fallbackDisplay: string | undefined, + strings: ScheduleDescribeStrings, +): string { + if (!schedule) return fallbackDisplay || strings.none; + + if (schedule.kind === "interval" && typeof schedule.minutes === "number") { + return describeInterval(schedule.minutes, strings); + } + + if (schedule.kind === "once" && schedule.run_at) { + return strings.onceAt.replace( + "{time}", + formatIsoLocal(schedule.run_at, false), + ); + } + + if (schedule.kind === "cron" && schedule.expr) { + const cronDesc = describeCronExpression(schedule.expr, strings); + if (cronDesc) return cronDesc; + } + + // Try the raw expression as a last attempt — for legacy jobs stored + // without ``kind``, the ``schedule_display`` field often *is* the cron + // expression. + if (fallbackDisplay) { + const cronDesc = describeCronExpression(fallbackDisplay, strings); + if (cronDesc) return cronDesc; + return fallbackDisplay; + } + if (schedule.display) return schedule.display; + if (schedule.expr) return schedule.expr; + return strings.none; +} + +function describeInterval( + minutes: number, + strings: ScheduleDescribeStrings, +): string { + if (minutes <= 0) return strings.none; + if (minutes % 1440 === 0) { + return strings.everyDays.replace("{n}", String(minutes / 1440)); + } + if (minutes % 60 === 0) { + return strings.everyHours.replace("{n}", String(minutes / 60)); + } + return strings.everyMinutes.replace("{n}", String(minutes)); +} + +/** Recognise the common, well-shaped cron patterns and return a + * human sentence for them. Returns ``null`` when the expression has any + * ranges, steps, or other complexity that would be misleading to + * "humanize" — caller falls back to displaying the raw expression so + * the user sees what's actually scheduled. + * + * Strictly 5-field only: the backend ``parse_schedule`` also accepts the + * 6-field ``minute hour dom month dow year`` form, but humanising those + * by destructuring only the first five fields would silently drop the + * year and mislead the user (e.g. ``0 9 * * * 2099`` would read as + * "Daily at 09:00"). 6+ field expressions intentionally fall through to + * the raw-string fallback in {@link describeSchedule}. */ +function describeCronExpression( + expr: string, + strings: ScheduleDescribeStrings, +): string | null { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return null; + const [minField, hourField, domField, monField, dowField] = parts; + + const month = monField === "*"; + if (!month) return null; // we don't try to humanize per-month rules + + const isLiteralOrList = (f: string) => + /^\d+(,\d+)*$/.test(f) || /^\*$/.test(f); + if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null; + if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null; + + // Star minutes/hours would mean "every minute" / "every hour" — we'd + // need a step-value handler ("*/15") to describe that cleanly, and + // that path is power-user territory. Bail to raw display. + if (minField === "*" || hourField === "*") return null; + + const minutes = minField.split(",").map((n) => parseInt(n, 10)); + const hours = hourField.split(",").map((n) => parseInt(n, 10)); + if (minutes.length !== 1 || hours.length !== 1) return null; + if ( + !Number.isFinite(minutes[0]) || + !Number.isFinite(hours[0]) || + hours[0] < 0 || + hours[0] > 23 || + minutes[0] < 0 || + minutes[0] > 59 + ) { + return null; + } + const time = `${pad2(hours[0])}:${pad2(minutes[0])}`; + + const domAll = domField === "*"; + const dowAll = dowField === "*"; + + if (domAll && dowAll) { + return strings.dailyAt.replace("{time}", time); + } + + if (domAll && !dowAll) { + const days = dowField + .split(",") + .map((n) => parseInt(n, 10)) + .filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[]; + if (days.length === 0) return null; + const labels = days + .map((d) => strings.weekdaysShort[d]) + .filter(Boolean) + .join(", "); + return strings.weeklyAt + .replace("{days}", labels) + .replace("{time}", time); + } + + if (!domAll && dowAll) { + const dom = parseInt(domField, 10); + if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null; + return strings.monthlyAt + .replace("{day}", strings.ordinal(dom)) + .replace("{time}", time); + } + + // Both day-of-month AND day-of-week set is unusual and cron's + // OR-semantics for that combo are confusing — fall back to raw. + return null; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** Format an ISO date for inline display. Drops the seconds + TZ + * suffix so the cron list stays compact. Falls back to the raw string + * if Date parsing fails. */ +function formatIsoLocal(iso: string, includeSeconds: boolean): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const yyyy = d.getFullYear(); + const mm = pad2(d.getMonth() + 1); + const dd = pad2(d.getDate()); + const hh = pad2(d.getHours()); + const mi = pad2(d.getMinutes()); + if (includeSeconds) { + return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`; + } + return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; +} + +/** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd"). + * Most non-English locales should just return ``String(day)`` from + * their ``ordinal`` override. */ +export function englishOrdinal(day: number): string { + const d = Math.floor(day); + if (!Number.isFinite(d) || d < 1) return String(day); + const lastTwo = d % 100; + if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`; + switch (d % 10) { + case 1: + return `${d}st`; + case 2: + return `${d}nd`; + case 3: + return `${d}rd`; + default: + return `${d}th`; + } +} diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index b9851c227..2583be969 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -21,7 +21,6 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Stats } from "@nous-research/ui/ui/components/stats"; import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card"; -import { Badge } from "@nous-research/ui/ui/components/badge"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; @@ -148,11 +147,17 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
-
+
{t.analytics.input}
-
+
{t.analytics.output}
@@ -192,13 +197,19 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
0 ? 1 : 0) }} + className="w-full" + style={{ + backgroundColor: + "color-mix(in srgb, var(--series-input-token) 70%, transparent)", + height: Math.max(inputH, total > 0 ? 1 : 0), + }} />
0 ? 1 : 0), }} /> @@ -261,12 +272,12 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { {d.sessions} - + {formatTokens(d.input_tokens)} - + {formatTokens(d.output_tokens)} @@ -319,11 +330,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { {m.sessions} - + {formatTokens(m.input_tokens)} {" / "} - + {formatTokens(m.output_tokens)} @@ -427,14 +438,24 @@ export default function AnalyticsPage() { }, [days, showTokens]); useLayoutEffect(() => { - const periodLabel = - PERIODS.find((p) => p.days === days)?.label ?? `${days}d`; + // Period selector + refresh both live in afterTitle so the controls + // sit immediately next to the page title instead of being pinned to + // the far-right `end` slot. The active period is conveyed by the + // filled (non-outlined) button — no redundant period badge. setAfterTitle( - - - {periodLabel} - - {showTokens !== false && ( + showTokens === false ? null : ( +
+ {PERIODS.map((p) => ( + + ))} - )} - , - ); - setEnd( - showTokens === false ? null : ( -
-
- {PERIODS.map((p) => ( - - ))} -
), ); + setEnd(null); return () => { setAfterTitle(null); setEnd(null); diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 741d6d5be..47c6ee843 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -8,6 +8,17 @@ import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { CronJob, ProfileInfo } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; +import { + DEFAULT_SCHEDULE_STATE, + ScheduleBuilder, +} from "@/components/ScheduleBuilder"; +import { + buildScheduleString, + describeSchedule, + englishOrdinal, + type ScheduleBuilderState, + type ScheduleDescribeStrings, +} from "@/lib/schedule"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; import { useModalBehavior } from "@/hooks/useModalBehavior"; @@ -57,12 +68,20 @@ function getJobTitle(job: CronJob): string { return job.id || "Cron job"; } -function getJobScheduleDisplay(job: CronJob): string { - return ( - asText(job.schedule_display) || - asText(job.schedule?.display) || - asText(job.schedule?.expr) || - "—" +function getJobScheduleDisplay( + job: CronJob, + strings: ScheduleDescribeStrings, +): string { + // Prefer a structured render so cron expressions like + // ``30 14 * * 1,3,5`` surface as "Weekly on Mon, Wed, Fri at 14:30" + // in the list instead of the raw five-field gibberish. Falls back + // through the existing chain (``schedule_display`` from the backend, + // then the structured ``display`` field, then the raw ``expr``) so + // legacy job rows still render *something* meaningful. + return describeSchedule( + job.schedule, + asText(job.schedule_display) || asText(job.schedule?.display), + strings, ); } @@ -102,13 +121,35 @@ export default function CronPage() { const [selectedProfile, setSelectedProfile] = useState("all"); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); - const { t } = useI18n(); + const { t, locale } = useI18n(); const { setEnd } = usePageHeader(); + // Translation surface for the human-readable schedule describer. + // English ordinals are a special case ("1st", "2nd", "23rd"); every + // other locale falls back to the plain numeric form, which avoids + // shipping incorrect grammar (e.g. naive "1th"/"2th" suffixes that + // don't exist in most languages). + // + // Built inline (not memoized) — the cron page renders a small job + // list, this is single-digit microseconds, and a useMemo here would + // just add boilerplate. + const scheduleDescribeStrings: ScheduleDescribeStrings = { + ...t.cron.scheduleDescribe, + weekdaysShort: t.cron.scheduleModes.weekdaysShort, + ordinal: locale === "en" ? englishOrdinal : (n: number) => String(n), + }; + // New job modal state const [createModalOpen, setCreateModalOpen] = useState(false); const [prompt, setPrompt] = useState(""); - const [schedule, setSchedule] = useState(""); + // The schedule is now constructed via the ScheduleBuilder; we keep + // the full builder state so flipping between modes during edit + // doesn't erase the user's intermediate inputs. The actual string + // sent to the backend is derived via ``buildScheduleString`` at + // submit time. + const [scheduleState, setScheduleState] = useState( + DEFAULT_SCHEDULE_STATE, + ); const [name, setName] = useState(""); const closeCreateModal = useCallback(() => setCreateModalOpen(false), []); const createModalRef = useModalBehavior({ @@ -161,8 +202,10 @@ export default function CronPage() { loadJobs(); }, [loadJobs]); + const scheduleString = buildScheduleString(scheduleState); + const handleCreate = async () => { - if (!prompt.trim() || !schedule.trim()) { + if (!prompt.trim() || !scheduleString) { showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error"); return; } @@ -171,7 +214,7 @@ export default function CronPage() { await api.createCronJob( { prompt: prompt.trim(), - schedule: schedule.trim(), + schedule: scheduleString, name: name.trim() || undefined, deliver, }, @@ -179,7 +222,7 @@ export default function CronPage() { ); showToast(t.common.create + " ✓", "success"); setPrompt(""); - setSchedule(""); + setScheduleState(DEFAULT_SCHEDULE_STATE); setName(""); setDeliver("local"); setCreateModalOpen(false); @@ -392,41 +435,34 @@ export default function CronPage() { />
-
-
- - setSchedule(e.target.value)} - /> -
+ -
- - -
+
+ +
@@ -617,7 +653,9 @@ export default function CronPage() {

)}
- {getJobScheduleDisplay(job)} + + {getJobScheduleDisplay(job, scheduleDescribeStrings)} + {t.cron.last}: {formatTime(job.last_run_at)} diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index f0e81d0f0..50cd69515 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -95,11 +95,17 @@ function TokenBar({ const total = input + output + cacheRead + reasoning; if (total === 0) return null; - const segments = [ - { value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" }, - { value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" }, - { value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" }, - { value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" }, + // Segments carry a CSS color value (hex or `var(--token)`) rather than + // a Tailwind class so the input/output series can pick up the active + // theme's `--series-*-token` vars — see `themes/types.ts` + // `ThemeSeriesColors`. The /60–/70 fade on the bar is applied via + // color-mix on the same value so themes don't need to ship two + // separate hex literals. + const segments: Array<{ color: string; label: string; value: number }> = [ + { value: cacheRead, color: "#60a5fa", label: "Cache Read" }, // tailwind blue-400 + { value: reasoning, color: "#c084fc", label: "Reasoning" }, // tailwind purple-400 + { value: input, color: "var(--series-input-token)", label: "Input" }, + { value: output, color: "var(--series-output-token)", label: "Output" }, ].filter((s) => s.value > 0); return ( @@ -109,8 +115,11 @@ function TokenBar({ {segments.map((s, i) => (
{/* Stepped fill pattern overlay */}
{segments.map((s, i) => ( - + {s.label} {formatTokens(s.value)} ))} @@ -152,7 +164,7 @@ function CapabilityBadges({ return (
{capabilities.supports_tools && ( - + Tools )} @@ -818,13 +830,24 @@ export default function ModelsPage() { }, []); useLayoutEffect(() => { - const periodLabel = - PERIODS.find((p) => p.days === days)?.label ?? `${days}d`; + // Period selector + refresh both live in afterTitle so the controls + // sit immediately next to the page title instead of being pinned to + // the far-right `end` slot. The active period is conveyed by the + // filled (non-outlined) button — no redundant period badge. setAfterTitle( - - - {periodLabel} - +
+ {PERIODS.map((p) => ( + + ))} - , - ); - setEnd( -
-
- {PERIODS.map((p) => ( - - ))} -
, ); + setEnd(null); return () => { setAfterTitle(null); setEnd(null); diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index f9cefd337..34a68800d 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -23,6 +23,7 @@ import { Hash, X, Play, + Eraser, Download, Pencil, Check, @@ -41,6 +42,7 @@ import { Markdown } from "@/components/Markdown"; import { PlatformsCard } from "@/components/PlatformsCard"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { Button } from "@nous-research/ui/ui/components/button"; +import { Checkbox } from "@nous-research/ui/ui/components/checkbox"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { Segmented } from "@nous-research/ui/ui/components/segmented"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; @@ -273,22 +275,14 @@ function SessionRow({ snippet, searchQuery, isExpanded, + isSelected, onToggle, + onSelectClick, onDelete, onRename, onExport, resumeInChatEnabled, -}: { - session: SessionInfo; - snippet?: string; - searchQuery?: string; - isExpanded: boolean; - onToggle: () => void; - onDelete: () => void; - onRename: (id: string, title: string) => Promise; - onExport: (id: string) => void; - resumeInChatEnabled: boolean; -}) { +}: SessionRowProps) { const [messages, setMessages] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -400,18 +394,44 @@ function SessionRow({ ); + // Selected rows get a stronger left-edge accent + tinted background so the + // selection state is unambiguous even when scrolling past the bulk-action + // bar at the top. Beat the is_active styling — explicit user selection + // takes priority over "this session is live". + const containerClasses = isSelected + ? "border-primary/40 bg-primary/[0.06]" + : session.is_active + ? "border-success/30 bg-success/[0.03]" + : "border-border"; + + // Clicking the checkbox must NOT toggle row expansion; selection and + // expansion are independent gestures. We bind ``onClick`` directly on + // the Checkbox (which Radix forwards to its underlying `` + )}
{showPagination && ( @@ -1126,12 +1404,77 @@ export default function SessionsPage() { className="shrink-0 sm:ml-auto" page={page} total={total} - onPageChange={setPage} + onPageChange={goToPage} /> )}
) : null} + {showList && selectedIds.size > 0 && ( +
+ + {t.sessions.selectedCount.replace( + "{count}", + String(selectedIds.size), + )} + + {filtered.some((s) => !selectedIds.has(s.id)) && ( + + )} + + +
+ )} + {showList ? ( filtered.length === 0 ? (
@@ -1148,16 +1491,20 @@ export default function SessionsPage() { ) : ( <>
- {filtered.map((s) => ( + {filtered.map((s, index) => ( setExpandedId((prev) => (prev === s.id ? null : s.id)) } + onSelectClick={(event) => + handleSelectClick(event, index, filtered) + } onDelete={() => sessionDelete.requestDelete(s.id)} onRename={handleRename} onExport={handleExport} @@ -1170,7 +1517,7 @@ export default function SessionsPage() { )} @@ -1238,6 +1585,20 @@ export default function SessionsPage() { ); } +interface SessionRowProps { + isExpanded: boolean; + isSelected: boolean; + onDelete: () => void; + onExport: (id: string) => void; + onRename: (id: string, title: string) => Promise; + onSelectClick: (event: React.MouseEvent) => void; + onToggle: () => void; + resumeInChatEnabled: boolean; + searchQuery?: string; + session: SessionInfo; + snippet?: string; +} + interface SessionsPaginationProps { className?: string; compact?: boolean; diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index d8a5b0e15..4fc09c71b 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; import { Activity, Brain, @@ -21,12 +22,12 @@ import { } from "lucide-react"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; -import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { Card, CardContent } from "@nous-research/ui/ui/components/card"; import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; @@ -236,16 +237,9 @@ export default function SystemPage() { }; // ── Memory ───────────────────────────────────────────────────────── - const setMemoryProvider = async (provider: string) => { - try { - await api.setMemoryProvider(provider); - showToast(`Memory provider: ${provider || "built-in only"}`, "success"); - loadAll(); - } catch (e) { - showToast(`Failed to set provider: ${e}`, "error"); - } - }; - + // Memory provider selection lives on the /plugins page now (see the + // read-only display + link below); the dropdown was intentionally + // dropped from this card during the admin-panel refresh. const memoryReset = useConfirmDelete({ onDelete: useCallback( async (target: string) => { @@ -748,26 +742,22 @@ export default function SystemPage() { -
- - -

- Set up a new provider's credentials with{" "} - hermes memory setup. -

+
+ + External provider:{" "} + + {memory?.active || "built-in only"} + + + + Change in Plugins → + + + New credentials:{" "} + hermes memory setup +
+
Built-in files — MEMORY.md:{" "} diff --git a/web/src/themes/context.tsx b/web/src/themes/context.tsx index 7af100206..9f3161b41 100644 --- a/web/src/themes/context.tsx +++ b/web/src/themes/context.tsx @@ -19,6 +19,7 @@ import type { ThemeLayoutVariant, ThemeListEntry, ThemePalette, + ThemeSeriesColors, ThemeTypography, } from "./types"; import { api } from "@/lib/api"; @@ -27,6 +28,21 @@ import { api } from "@/lib/api"; * a visible flash of the default palette on theme-overridden installs. */ const STORAGE_KEY = "hermes-dashboard-theme"; +/** Renames of built-in theme keys we've shipped previously. Without this, + * users who saved one of the old names in localStorage (or had it + * persisted server-side) would silently fall back to `defaultTheme` + * because the lookup in `resolveTheme` no longer finds the stale key. + * Keep entries here until enough release cycles have passed that we can + * reasonably assume nobody still has the old value persisted. */ +const THEME_NAME_ALIASES: Record = { + // Renamed during the LENS_5I port + Nous-blue rebrand. + "lens-5i": "nous-blue", +}; + +function migrateThemeName(name: string): string { + return THEME_NAME_ALIASES[name] ?? name; +} + /** Tracks fontUrls we've already injected so multiple theme switches don't * pile up tags. Keyed by URL. */ const INJECTED_FONT_URLS = new Set(); @@ -126,6 +142,30 @@ function overrideVars( return out; } +/** Map data-series accents to their CSS vars. Themes omit either field to + * inherit the `:root` default from `index.css`; when omitted we also + * proactively clear any leftover value from a previous theme so switches + * don't carry stale colors. */ +const SERIES_KEY_TO_VAR: Record = { + inputTokenAccent: "--series-input-token", + outputTokenAccent: "--series-output-token", +}; + +const ALL_SERIES_VARS = Object.values(SERIES_KEY_TO_VAR); + +function seriesColorVars( + series: ThemeSeriesColors | undefined, +): Record { + if (!series) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(series)) { + if (!value) continue; + const cssVar = SERIES_KEY_TO_VAR[key as keyof ThemeSeriesColors]; + if (cssVar) out[cssVar] = value; + } + return out; +} + // --------------------------------------------------------------------------- // Asset + component-style + layout variant vars // --------------------------------------------------------------------------- @@ -268,6 +308,12 @@ function applyTheme(theme: DashboardTheme) { for (const cssVar of ALL_OVERRIDE_VARS) { root.style.removeProperty(cssVar); } + // Same clear-then-set for series colors so a theme that defines them + // (e.g. Nous Blue) doesn't leave its values behind when the user + // switches to a theme that inherits the `:root` defaults. + for (const cssVar of ALL_SERIES_VARS) { + root.style.removeProperty(cssVar); + } // Clear dynamic (asset/component) vars from the previous theme so the // new one starts clean — otherwise stale notched clip-paths, hero URLs, // etc. would bleed across theme switches. @@ -287,6 +333,7 @@ function applyTheme(theme: DashboardTheme) { ...typographyVars(theme.typography), ...layoutVars(theme.layout), ...overrideVars(theme.colorOverrides), + ...seriesColorVars(theme.seriesColors), ...assetMap, ...componentMap, }; @@ -313,7 +360,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) { /** Name of the currently active theme (built-in id or user YAML name). */ const [themeName, setThemeName] = useState(() => { if (typeof window === "undefined") return "default"; - return window.localStorage.getItem(STORAGE_KEY) ?? "default"; + const stored = window.localStorage.getItem(STORAGE_KEY) ?? "default"; + const migrated = migrateThemeName(stored); + // Write the migrated name back so future reads converge on the new + // key and we eventually retire the alias entry. + if (migrated !== stored) { + window.localStorage.setItem(STORAGE_KEY, migrated); + } + return migrated; }); /** All selectable themes (shown in the picker). Starts with just the @@ -377,9 +431,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) { } if (Object.keys(defs).length > 0) setUserThemeDefs(defs); } - if (resp.active && resp.active !== themeName) { - setThemeName(resp.active); - window.localStorage.setItem(STORAGE_KEY, resp.active); + if (resp.active) { + const migratedActive = migrateThemeName(resp.active); + if (migratedActive !== themeName) { + setThemeName(migratedActive); + window.localStorage.setItem(STORAGE_KEY, migratedActive); + } + // If the server is still persisting the stale key, push the + // migrated value back so it converges too — otherwise every + // future page load would re-trigger this branch. + if (migratedActive !== resp.active) { + api.setTheme(migratedActive).catch(() => {}); + } } }) .catch(() => {}); diff --git a/web/src/themes/presets.ts b/web/src/themes/presets.ts index 35f5e028b..6e1b05384 100644 --- a/web/src/themes/presets.ts +++ b/web/src/themes/presets.ts @@ -184,6 +184,100 @@ export const roseTheme: DashboardTheme = { }, }; +/** + * Nous Blue — the inverted "light mode" Hermes look, ported from the + * LENS_5I overlay preset in `@nous-research/ui`. + * + * Unlike the other built-ins (which paint dark color directly on the + * canvas), this theme relies on ``'s foreground inversion + * layer: an opaque white sheet at z-200 with `mix-blend-mode: difference` + * that flips the entire stack below it. Authoring colors stay dark + * (`#170d02` brown background, `#FFAC02` orange midground), and the + * inversion converts them to their visual complements at paint time — + * the orange midground reads as #0053FD Nous-blue on screen, against a + * cream `#E8F2FD` canvas. + * + * Note on bg blend mode: the DS Lens uses `multiply` for LENS_5I because + * nousnet-web's is white; hermes-agent's App root is `bg-black`, + * so we leave the bg layer's blend mode at the `difference` default — + * `difference(#170d02, #000)` passes the bg through unchanged, and the + * subsequent FG-difference layer then inverts it to cream. Using + * `multiply` here would collapse the bg to pure black against the + * `bg-black` root and produce a plain-white canvas instead of the + * intended cream-blue. + * + * Source of truth for the palette: `design-language/src/ui/components/ + * overlays/lens.ts` (LENS_5I export). + */ +export const nousBlueTheme: DashboardTheme = { + name: "nous-blue", + label: "Nous Blue", + description: "Light mode — vivid Nous-blue accents on cream canvas", + palette: { + background: { hex: "#170d02", alpha: 1 }, + midground: { hex: "#FFAC02", alpha: 1 }, + foreground: { hex: "#FFFFFF", alpha: 1 }, + // Same warm-amber as nousnet-web's overlay glow; after the FG + // inversion it reads as a cool ultraviolet vignette in the top-left. + warmGlow: "rgba(255, 172, 2, 0.18)", + // Noise sits above the FG inversion and is NOT flipped, so a softer + // multiplier keeps it from speckling over the bright post-inversion + // canvas. + noiseOpacity: 0.4, + }, + typography: DEFAULT_TYPOGRAPHY, + layout: DEFAULT_LAYOUT, + // Inverted page: the embedded terminal is below the FG layer too, so + // a `#000000` source paints as visual white — i.e. a proper light-mode + // terminal pane. xterm picks lighter palette colors against the "black" + // canvas, which then read as dark text on screen post-inversion. + terminalBackground: "#000000", + componentStyles: { + backdrop: { + // Lower than LENS_5I.Lens.fillerOpacity (0.06). The filler texture + // gets amplified post-inversion: small variations against the deep + // `#170d02` source bg are barely visible, but those same variations + // against the bright `#E8F2FD` post-inversion canvas read as a + // heavy cloud/marble pattern — especially on near-empty pages + // (loading spinners, blank states). 0.02 keeps subtle grain + // without overwhelming the canvas. + fillerOpacity: "0.02", + }, + }, + // Pre-invert absolute-hex tokens so they read as their familiar colors + // through the FG difference layer. e.g. source #04D3C9 (cyan) is what + // gets painted, and `255 - channel` flips it to #FB2C36 (red) on screen. + // Without these, the default destructive/success/warning tokens would + // appear as their unintuitive complements. + colorOverrides: { + destructive: "#04d3c9", + destructiveForeground: "#000000", + success: "#b5217f", + warning: "#0042c7", + }, + // Pre-inverted data-series accents for the Analytics/Models token + // charts. The defaults (#ffe6cb cream + #34d399 emerald) would render + // through the FG difference layer as dark navy + hot-coral on the + // bright Nous-blue canvas — the coral is the "red" users see for + // Output values without these overrides. Source → on-screen: + // Input: #ffe6cb → #001934 (dark navy) ← unchanged + // Output: #ffac02 → #0053fd (vivid Nous-blue) ← brand accent + // Input keeps the cream source so it stays a neutral, low-contrast + // dark-blue against the cream canvas; output paints as the brand + // Nous-blue so the "primary" series in token-flow charts reads as + // the highlight color, matching the rest of the inverted UI chrome. + seriesColors: { + inputTokenAccent: "#ffe6cb", + outputTokenAccent: "#ffac02", + }, + // Explicit picker swatch — the raw palette hex (`#170d02`, `#FFAC02`, + // amber rgba) doesn't reflect what users see after the FG inversion, + // so we paint the post-inversion visual triplet directly: + // white → vivid Nous-blue → cream/light-blue + // matching the actual on-screen rendering of the theme. + swatchColors: ["#FFFFFF", "#0053FD", "#E8F2FD"], +}; + /** * Same look as ``defaultTheme`` but with a larger root font size, looser * line-height, and ``spacious`` density so every rem-based size in the @@ -208,6 +302,7 @@ export const defaultLargeTheme: DashboardTheme = { export const BUILTIN_THEMES: Record = { default: defaultTheme, "default-large": defaultLargeTheme, + "nous-blue": nousBlueTheme, midnight: midnightTheme, ember: emberTheme, mono: monoTheme, diff --git a/web/src/themes/types.ts b/web/src/themes/types.ts index d4333f2ad..811d67c3c 100644 --- a/web/src/themes/types.ts +++ b/web/src/themes/types.ts @@ -119,6 +119,25 @@ export interface ThemeComponentStyles { page?: Record; } +/** Data-series accent colors for chart + table visualisations (Analytics, + * Models, etc.). Themes provide hex strings; the provider emits them as + * `--series-input-token` / `--series-output-token` CSS vars consumed + * inline by pages that render input-vs-output token flows. Themes can + * omit either field to inherit the default token defined in + * `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output). + * + * Inverted-lens themes (e.g. Nous Blue) must pre-invert these hex + * values so they read as their intended visual color after the FG + * difference layer flips them (`out = 255 − channel`). E.g. to make + * output paint as Nous-blue `#0053FD` on screen, set + * `outputTokenAccent: "#FFAC02"` — the difference math reverses it. */ +export interface ThemeSeriesColors { + /** Input-tokens series accent (Analytics chart bars + table values). */ + inputTokenAccent?: string; + /** Output-tokens series accent. */ + outputTokenAccent?: string; +} + /** Optional hex overrides keyed by shadcn-compat token name (without the * `--color-` prefix). Any key set here wins over the DS cascade. */ export interface ThemeColorOverrides { @@ -162,6 +181,15 @@ export interface DashboardTheme { /** Per-component CSS-var overrides. See `ThemeComponentStyles`. */ componentStyles?: ThemeComponentStyles; colorOverrides?: ThemeColorOverrides; + /** Data-series accent colors for Analytics/Models token charts. + * See `ThemeSeriesColors` for inversion-aware values. */ + seriesColors?: ThemeSeriesColors; + /** Explicit 3-color swatch override for the theme picker. Use when the + * palette's raw hex values don't reflect what users see on screen — + * e.g. inverted "lens" themes whose foreground-difference layer flips + * the authored colors to their visual complements. Order matches the + * default swatch cells: [background, midground, warmGlow]. */ + swatchColors?: [string, string, string]; /** Background color for the embedded terminal pane (xterm.js). * Hex string. Defaults to `"#000000"` when absent. */ terminalBackground?: string;