diff --git a/README.md b/README.md index 54552606..4c3a11b0 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,7 @@ Need help with `PATH`, API keys, provider/model matching, or JSON errors? See th ## 🌐 WebUI -The WebUI ships **inside the published wheel** — no extra build step. Just enable the WebSocket channel and open it in your browser. +The WebUI ships **inside the published wheel** — no extra build step. It is the browser workbench for chat sessions, workspace controls, Apps, Skills, Automations, and settings. For the full user guide, see [`docs/webui.md`](./docs/webui.md).

nanobot webui preview @@ -383,12 +383,12 @@ nanobot gateway **3. Open the WebUI** -Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan). +Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access). The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI. > [!TIP] -> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow. +> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the source-tree, Vite dev server, build, and test workflow. ## 🏗️ Architecture diff --git a/docs/README.md b/docs/README.md index 53281a45..9b4ef7b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ If you find a docs mistake, outdated command, or confusing step, please open an |---|---|---| | New to terminals and config files | [`start-without-technical-background.md`](./start-without-technical-background.md) | [`troubleshooting.md`](./troubleshooting.md) if the first reply fails | | Comfortable pasting commands and JSON | [`quick-start.md`](./quick-start.md) | [`provider-cookbook.md`](./provider-cookbook.md) for pasteable provider setups | -| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`../webui/README.md`](../webui/README.md), and [`deployment.md`](./deployment.md) | +| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`webui.md`](./webui.md), and [`deployment.md`](./deployment.md) | | Integrating or extending nanobot | [`architecture.md`](./architecture.md) | [`configuration.md`](./configuration.md), [`openai-api.md`](./openai-api.md), [`python-sdk.md`](./python-sdk.md), [`development.md`](./development.md), and [`channel-plugin-guide.md`](./channel-plugin-guide.md) | ## Start Here @@ -38,7 +38,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask | Next goal | Read | First check | |---|---|---| -| Use nanobot in a browser | [`../webui/README.md`](../webui/README.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` | +| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` | | Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running | | Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` | | Understand before operating long-term | [`concepts.md`](./concepts.md) | Know what config, workspace, gateway, sessions, memory, and tools mean | @@ -48,7 +48,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask | Goal | Read | Outcome | |---|---|---| -| Open the bundled browser UI | [`../webui/README.md`](../webui/README.md) | WebUI on port `8765`, or Vite HMR when developing the frontend | +| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings | | Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control | | Use slash commands and periodic tasks | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, heartbeat tasks, and chat-side controls | | Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior | diff --git a/docs/architecture.md b/docs/architecture.md index 665fad1c..97c7afe5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,8 @@ WebUI source lives in `webui/`. The production build is written to `nanobot/web/ Useful docs: -- [`../webui/README.md`](../webui/README.md) for WebUI use and development; +- [`webui.md`](./webui.md) for the WebUI user guide; +- [`../webui/README.md`](../webui/README.md) for frontend source development; - [`websocket.md`](./websocket.md) for protocol details. ## Tools diff --git a/docs/deployment.md b/docs/deployment.md index e076a8f1..0c398880 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -54,7 +54,7 @@ Restart the deployed process after editing `config.json`. Long-running processes > } > ``` > -> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured — see [`webui/README.md`](../webui/README.md) for details. +> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details. ### Docker Compose diff --git a/docs/quick-start.md b/docs/quick-start.md index 7ba5dcc7..819b8a4b 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -273,7 +273,7 @@ Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. | Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) | | Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) | | Understand provider/model matching | [`providers.md`](./providers.md) | -| Open the bundled browser UI | [`../webui/README.md`](../webui/README.md) | +| Open the bundled browser UI | [`webui.md`](./webui.md) | | Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [`chat-apps.md`](./chat-apps.md) | | Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) | | Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) | diff --git a/docs/start-without-technical-background.md b/docs/start-without-technical-background.md index 7e23e139..32cdc095 100644 --- a/docs/start-without-technical-background.md +++ b/docs/start-without-technical-background.md @@ -401,7 +401,7 @@ nanobot gateway To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`. -If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`../webui/README.md`](../webui/README.md). +If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md). ### Connect a Chat App diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6ea134ac..ee374422 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -205,7 +205,7 @@ http://127.0.0.1:8765 If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret. -See [`../webui/README.md`](../webui/README.md) for LAN and development setup. +See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development. ## Chat App Problems diff --git a/docs/webui.md b/docs/webui.md new file mode 100644 index 00000000..82eb12dd --- /dev/null +++ b/docs/webui.md @@ -0,0 +1,168 @@ +# WebUI + +The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already +works, when you want a persistent chat workspace, visible agent activity, +workspace controls, Apps, Skills, settings, and Automations in one place. + +The published `nanobot-ai` wheel already includes the WebUI bundle. You only need +the `webui/` source directory when you are changing the frontend itself. + +## Open the WebUI + +First confirm your provider and model can answer: + +```bash +nanobot agent -m "Hello!" +``` + +Then merge the WebSocket channel into your existing `~/.nanobot/config.json`: + +```json +{ "channels": { "websocket": { "enabled": true } } } +``` + +If you are new to JSON snippets, see +[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets). + +Start the gateway: + +```bash +nanobot gateway +``` + +Leave the gateway running and open +[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the +WebSocket channel on port `8765` by default. The gateway health endpoint, +`18790` by default, is not the browser UI. + +## What It Is For + +| Area | Use it for | +|---|---| +| Chat | Start, switch, search, fork, and delete browser sessions | +| Agent activity | See thinking, tool calls, file activity, command output, and generated artifacts in context | +| Workspace | Pick the project workspace before asking for file or shell work | +| Access | Choose the access mode for local capabilities allowed by your gateway configuration | +| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets | +| Apps | Install, test, update, and use local CLI App adapters and MCP presets | +| Skills | Inspect available built-in and workspace skills before relying on them | +| Automations | Review, search, run, pause, edit, and delete scheduled agent turns | +| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options | + +## Chat Workspace + +The sidebar is the session switcher. A session keeps its own history, title, +workspace metadata, and linked automations. Use a new session when you want a +separate context; use fork when you want to continue from an existing point +without changing the original thread. + +The message timeline shows both user-visible replies and agent activity. Long +tool or reasoning sections can be expanded when you need the details. + +## Workspace and Access + +Use the workspace picker before starting project-specific work. This gives the +agent the right project context for file paths, shell commands, and session +metadata. + +The access control in the composer controls the local capability level for the +chat. It does not bypass your gateway, provider, shell sandbox, or operating +system configuration; it only selects among the capabilities that are already +available to this WebUI session. + +## Composer + +The composer supports plain messages, image attachments, voice input when +transcription is configured, slash commands, and `@` mentions for installed Apps +or MCP presets. The model badge shows the current model or preset and links back +to model settings when setup is incomplete. + +For image generation, configure an image provider first and then use the WebUI +image mode from the composer. See [`image-generation.md`](./image-generation.md) +for provider setup and output behavior. + +## Apps + +Open Apps from the sidebar or settings navigation to manage integrations that +nanobot can call from a chat. CLI Apps install local adapters that nanobot runs +on your machine; they do not modify the native apps themselves. MCP presets add +predefined MCP server configurations. + +After an App or MCP preset is available, mention it from the composer with `@` +to attach that capability to the next message. + +## Skills + +The Skills view shows the skill instructions available to the agent, including +built-in skills and workspace-provided skills. Check this view when you want to +know whether nanobot already has a focused workflow for a task before you ask it +to perform that task. + +## Automations + +Automations are scheduled agent turns. They should be created from the chat, +channel, or session where they are supposed to run so nanobot keeps the correct +target context. + +Use the Automations view to: + +- Filter by all, active, paused, needs-attention, or system jobs. +- Search by task name, message, linked chat, schedule, or status. +- Sort by next run, last run, updated time, or name. +- Run now, pause or resume, edit, or delete user-created automations. +- Inspect protected system automations without changing them. + +Search accepts plain text and field filters such as `name:backup`, +`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, and `status:paused`. + +An automation without a linked chat cannot be enabled or run from the WebUI, +because nanobot would not know where to deliver the scheduled turn. Recreate it +from the target chat or channel so the automation has complete context. + +## Settings + +Settings is the control surface for the browser session and gateway-backed +runtime configuration. Use it to review or adjust model presets, provider +visibility, image generation, voice transcription, web tools, Apps, Automations, +Skills, runtime identity, and advanced safety controls. + +Some settings take effect immediately. Runtime settings that affect the gateway +or agent process may require a restart; the WebUI shows that requirement next to +the relevant control. + +## LAN Access + +To open the WebUI from another device on the same network, bind the WebSocket +channel to all interfaces and set a token or token issue secret: + +```json +{ + "channels": { + "websocket": { + "enabled": true, + "host": "0.0.0.0", + "port": 8765, + "tokenIssueSecret": "your-secret-here" + } + } +} +``` + +The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or +`tokenIssueSecret` is configured. After the gateway starts, open +`http://:8765` from the other device and enter the secret in the login +form. + +## Troubleshooting + +If the page does not open, check these in order: + +1. `nanobot agent -m "Hello!"` works in the same Python environment. +2. The WebSocket channel is enabled in `~/.nanobot/config.json`. +3. `nanobot gateway` is still running. +4. You are opening port `8765`, not the gateway health port. +5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret. + +For detailed diagnostics, see +[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems). +For frontend development, see [`../webui/README.md`](../webui/README.md). diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 3c18d8e9..3e5b6783 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -896,6 +896,7 @@ class WebSocketChannel(BaseChannel): goal_state=gs_blob, metadata=msg.metadata, ) + await self.send_session_updated(msg.chat_id, scope="thread") return if msg.metadata.get("_session_updated"): if conns: @@ -1146,8 +1147,8 @@ class WebSocketChannel(BaseChannel): await self._safe_send_to(connection, raw, label=" goal_status ") async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None: - """Notify clients that session metadata changed outside the main turn.""" - conns = list(self._subs.get(chat_id, ())) + """Notify WebUI clients that a session row should refresh.""" + conns = list(self._conn_chats) if not conns: return body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id} diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 16fab16b..23426bec 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -136,6 +136,10 @@ class CronService: """Service for managing and executing scheduled jobs.""" _MAX_RUN_HISTORY = 20 + _UNBOUND_AGENT_JOB_REASON = ( + "agent cron payload is missing bound session delivery context; " + "recreate it from a chat session" + ) def __init__( self, @@ -154,6 +158,42 @@ class CronService: self._timer_active = False self.max_sleep_ms = max_sleep_ms + def _is_unbound_agent_job(self, job: CronJob) -> bool: + return job.payload.kind == "agent_turn" and not is_bound_cron_job(job) + + def _enforce_agent_binding(self, job: CronJob) -> bool: + """Disable user cron jobs that cannot be routed to a concrete session.""" + if not self._is_unbound_agent_job(job): + return False + if ( + not job.enabled + and job.state.next_run_at_ms is None + and job.state.last_status == "error" + and job.state.last_error + ): + return False + + job.enabled = False + job.state.next_run_at_ms = None + job.state.last_status = "error" + job.state.last_error = self._UNBOUND_AGENT_JOB_REASON + job.updated_at_ms = max(job.updated_at_ms, _now_ms()) + logger.warning( + "Cron: disabled unbound agent job '{}' ({}): {}", + job.name, + job.id, + self._UNBOUND_AGENT_JOB_REASON, + ) + return True + + def _enforce_store_agent_bindings(self) -> bool: + if not self._store: + return False + changed = False + for job in self._store.jobs: + changed = self._enforce_agent_binding(job) or changed + return changed + def _load_jobs(self) -> tuple[list[CronJob], int] | None: """Load jobs from disk. @@ -312,6 +352,8 @@ class CronService: jobs, version = loaded self._store = CronStore(version=version, jobs=jobs) self._merge_action() + if self._enforce_store_agent_bindings() and self._running: + self._save_store() return self._store @@ -456,6 +498,8 @@ class CronService: return now = _now_ms() for job in self._store.jobs: + if self._enforce_agent_binding(job): + continue if job.enabled: job.state.next_run_at_ms = _compute_next_run(job.schedule, now) @@ -638,6 +682,7 @@ class CronService: delete_after_run=delete_after_run, ) _normalize_agent_turn_job(job) + self._enforce_agent_binding(job) if self._running: store = self._load_store() store.jobs.append(job) @@ -695,7 +740,8 @@ class CronService: if job.id == job_id: job.enabled = enabled job.updated_at_ms = _now_ms() - if enabled: + self._enforce_agent_binding(job) + if job.enabled: job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) else: job.state.next_run_at_ms = None @@ -747,10 +793,13 @@ class CronService: if delete_after_run is not None: job.delete_after_run = delete_after_run _normalize_agent_turn_job(job) + self._enforce_agent_binding(job) job.updated_at_ms = _now_ms() if job.enabled: job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) + else: + job.state.next_run_at_ms = None if self._running: self._save_store() @@ -769,6 +818,10 @@ class CronService: store = self._load_store() for job in store.jobs: if job.id == job_id: + if self._is_unbound_agent_job(job): + self._enforce_agent_binding(job) + self._save_store() + return False if not force and not job.enabled: return False await self._execute_job(job) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index c87dfd09..55e748d6 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -1,14 +1,18 @@ -"""Session-scoped automation payloads for the embedded WebUI.""" +"""Automation payloads for the embedded WebUI.""" from __future__ import annotations from collections.abc import Collection from typing import Any, Protocol +from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.cron.types import CronJob +from nanobot.session.manager import _message_preview_text class _CronServiceLike(Protocol): + def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: ... + def list_bound_cron_jobs_for_session( self, session_key: str, @@ -17,6 +21,10 @@ class _CronServiceLike(Protocol): ) -> list[CronJob]: ... +class _SessionManagerLike(Protocol): + def read_session_file(self, key: str) -> dict[str, Any] | None: ... + + def session_automation_jobs( cron_service: _CronServiceLike | None, session_key: str, @@ -45,16 +53,50 @@ def session_automations_payload( } +def all_automations_payload( + cron_service: _CronServiceLike | None, + *, + session_manager: _SessionManagerLike | None = None, + pending_job_ids: Collection[str] | None = None, +) -> dict[str, Any]: + """Return all cron jobs visible to the WebUI automation manager.""" + jobs = cron_service.list_jobs(include_disabled=True) if cron_service is not None else [] + return { + "jobs": serialize_automation_jobs( + jobs, + pending_job_ids=pending_job_ids, + include_details=True, + session_manager=session_manager, + ) + } + + def serialize_automation_jobs( jobs: list[CronJob], *, pending_job_ids: Collection[str] | None = None, + include_details: bool = False, + session_manager: _SessionManagerLike | None = None, ) -> list[dict[str, Any]]: - return [_serialize_job(job, pending=job.id in (pending_job_ids or ())) for job in jobs] + return [ + _serialize_job( + job, + pending=job.id in (pending_job_ids or ()), + include_details=include_details, + session_manager=session_manager, + ) + for job in jobs + ] -def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]: - return { +def _serialize_job( + job: CronJob, + *, + pending: bool = False, + include_details: bool = False, + session_manager: _SessionManagerLike | None = None, +) -> dict[str, Any]: + payload = { "id": job.id, "name": job.name, "enabled": job.enabled, @@ -74,3 +116,80 @@ def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]: "pending": pending, }, } + if not include_details: + return payload + + payload["protected"] = job.payload.kind == "system_event" + payload["delete_after_run"] = job.delete_after_run + payload["created_at_ms"] = job.created_at_ms + payload["updated_at_ms"] = job.updated_at_ms + payload["payload"].update({"kind": job.payload.kind}) + payload["state"].update( + { + "last_run_at_ms": job.state.last_run_at_ms, + "last_error": job.state.last_error, + "run_history": [ + { + "run_at_ms": record.run_at_ms, + "status": record.status, + "duration_ms": record.duration_ms, + "error": record.error, + } + for record in job.state.run_history[-5:] + ], + } + ) + payload["origin"] = _origin_payload(job, session_manager) + return payload + + +def _origin_payload( + job: CronJob, + session_manager: _SessionManagerLike | None, +) -> dict[str, Any] | None: + channel = job.payload.origin_channel + chat_id = job.payload.origin_chat_id + if not channel or not chat_id: + return None + title = "" + preview = "" + if channel != "websocket": + return { + "channel": channel, + "title": title, + "preview": preview, + } + + session_key = f"{channel}:{chat_id}" + if session_manager is not None: + data = session_manager.read_session_file(session_key) + if isinstance(data, dict): + title = str(data.get("title") or "") + preview = _session_preview(data.get("messages")) + + return { + "session_key": session_key, + "channel": channel, + "chat_id": chat_id, + "title": title, + "preview": preview, + } + + +def _session_preview(messages: Any) -> str: + if not isinstance(messages, list): + return "" + fallback_preview = "" + for message in messages: + if not isinstance(message, dict): + continue + if message.get(CRON_HISTORY_META) is True: + continue + text = _message_preview_text(message) + if not text: + continue + if message.get("role") == "user": + return text + if not fallback_preview and message.get("role") == "assistant": + fallback_preview = text + return fallback_preview diff --git a/nanobot/webui/session_list_index.py b/nanobot/webui/session_list_index.py index 4fba2ca1..aaadc97a 100644 --- a/nanobot/webui/session_list_index.py +++ b/nanobot/webui/session_list_index.py @@ -9,11 +9,13 @@ from __future__ import annotations import json import os +from datetime import datetime from pathlib import Path from typing import Any from loguru import logger +from nanobot.config.paths import get_webui_dir from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.session.manager import ( _SESSION_LIST_PREVIEW_MAX_CHARS, @@ -26,6 +28,8 @@ from nanobot.session.manager import ( _INDEX_VERSION = 1 _INDEX_FILENAME = ".webui_session_index.json" +_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns" +_WEBUI_ACTIVITY_SIZE = "webui_activity_size" def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]: @@ -117,7 +121,13 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path) -> bool: signature = _file_signature(path) except OSError: return False - return row.get("mtime_ns") == signature["mtime_ns"] and row.get("size") == signature["size"] + activity_signature = _webui_activity_signature(str(row.get("key"))) + return ( + row.get("mtime_ns") == signature["mtime_ns"] + and row.get("size") == signature["size"] + and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS] + and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE] + ) def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]: @@ -155,17 +165,69 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str: return fallback_preview +def _webui_activity_paths(session_key: str) -> list[Path]: + stem = SessionManager.safe_key(session_key) + webui_dir = get_webui_dir() + return [ + webui_dir / f"{stem}.jsonl", + webui_dir / f"{stem}.json", + ] + + +def _webui_activity_signature(session_key: str) -> dict[str, int]: + latest_mtime_ns = 0 + total_size = 0 + for path in _webui_activity_paths(session_key): + try: + stat = path.stat() + except OSError: + continue + if not path.is_file(): + continue + latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns) + total_size += stat.st_size + return { + _WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns, + _WEBUI_ACTIVITY_SIZE: total_size, + } + + +def _webui_activity_updated_at(signature: dict[str, int]) -> str | None: + mtime_ns = signature.get(_WEBUI_ACTIVITY_MTIME_NS, 0) + if mtime_ns <= 0: + return None + return datetime.fromtimestamp(mtime_ns / 1_000_000_000).isoformat() + + +def _timestamp(value: str | None) -> float: + if not value: + return 0.0 + try: + return datetime.fromisoformat(value).timestamp() + except ValueError: + return 0.0 + + +def _latest_updated_at(stored: str | None, activity: str | None) -> str | None: + if _timestamp(activity) > _timestamp(stored): + return activity + return stored + + def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]: signature = _file_signature(path) + activity_signature = _webui_activity_signature(session.key) + activity_updated_at = _webui_activity_updated_at(activity_signature) return { "key": session.key, "created_at": session.created_at.isoformat(), - "updated_at": session.updated_at.isoformat(), + "updated_at": _latest_updated_at(session.updated_at.isoformat(), activity_updated_at), "title": _metadata_title(session.metadata), "preview": _preview_from_messages(session.messages), "file": path.name, "mtime_ns": signature["mtime_ns"], "size": signature["size"], + **activity_signature, } @@ -207,15 +269,19 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, if not fallback_preview and item.get("role") == "assistant": fallback_preview = text signature = _file_signature(path) + key = data.get("key") or fallback_key + activity_signature = _webui_activity_signature(key) + activity_updated_at = _webui_activity_updated_at(activity_signature) return { - "key": data.get("key") or fallback_key, + "key": key, "created_at": data.get("created_at"), - "updated_at": data.get("updated_at"), + "updated_at": _latest_updated_at(data.get("updated_at"), activity_updated_at), "title": _metadata_title(data.get("metadata", {})), "preview": preview or fallback_preview, "file": path.name, "mtime_ns": signature["mtime_ns"], "size": signature["size"], + **activity_signature, } except Exception: repaired = session_manager._repair(fallback_key) diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 8d2694f6..9e2981f9 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -17,12 +17,15 @@ import time from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any +from urllib.parse import unquote from loguru import logger from websockets.http11 import Request as WsRequest from websockets.http11 import Response from nanobot.command.builtin import builtin_command_palette +from nanobot.cron.session_turns import is_bound_cron_job +from nanobot.cron.types import CronJob, CronSchedule from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload @@ -64,6 +67,7 @@ from nanobot.webui.http_utils import ( ) from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.session_automations import ( + all_automations_payload, serialize_automation_jobs, session_automation_jobs, session_automations_payload, @@ -79,6 +83,7 @@ from nanobot.webui.transcript import build_webui_thread_response from nanobot.webui.workspaces import WebUIWorkspaceController _SLOW_WEBUI_HTTP_LOG_MS = 1_000 +_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values" if TYPE_CHECKING: from nanobot.bus.queue import MessageBus @@ -87,8 +92,6 @@ if TYPE_CHECKING: def _decode_api_key(raw_key: str) -> str | None: - from urllib.parse import unquote - key = unquote(raw_key) _api_key_re = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$") if _api_key_re.match(key) is None: @@ -236,6 +239,11 @@ class GatewayHTTPHandler: if response is not None: return response + # Automation routes + response = await self._dispatch_automation_routes(request, got) + if response is not None: + return response + # Misc routes response = await self._dispatch_misc_routes(connection, request, got) if response is not None: @@ -514,6 +522,112 @@ class GatewayHTTPHandler: delete_webui_thread(decoded_key) return _http_json_response({"deleted": bool(deleted)}) + # -- Automation routes -------------------------------------------------- + + async def _dispatch_automation_routes( + self, + request: WsRequest, + got: str, + ) -> Response | None: + if got == "/api/webui/automations": + return self._handle_webui_automations(request) + m = re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", got) + if m: + return await self._handle_webui_automation_action(request, m.group(1)) + return None + + def _pending_cron_job_ids_for_all(self) -> set[str]: + if self.cron_service is None or self.cron_pending_job_ids is None: + return set() + pending: set[str] = set() + for job in self.cron_service.list_jobs(include_disabled=True): + session_key = job.payload.session_key + if not session_key and job.payload.origin_channel and job.payload.origin_chat_id: + session_key = f"{job.payload.origin_channel}:{job.payload.origin_chat_id}" + if session_key: + pending.update(self.cron_pending_job_ids(session_key)) + return pending + + def _handle_webui_automations(self, request: WsRequest) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + return _http_json_response( + all_automations_payload( + self.cron_service, + session_manager=self.session_manager, + pending_job_ids=self._pending_cron_job_ids_for_all(), + ) + ) + + async def _handle_webui_automation_action( + self, + request: WsRequest, + action: str, + ) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + if self.cron_service is None: + return _http_error(503, "cron service unavailable") + + query = _parse_query(request.path) + job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip() + if not job_id: + return _http_error(400, "missing automation id") + job = self.cron_service.get_job(job_id) + if job is None: + return _http_error(404, "automation not found") + if job.payload.kind == "system_event": + return _http_error(403, "system automation is protected") + if action in {"enable", "run"} and not is_bound_cron_job(job): + return _http_error(409, "automation has no linked chat") + + if action == "enable": + if self.cron_service.enable_job(job_id, enabled=True) is None: + return _http_error(404, "automation not found") + elif action == "disable": + if self.cron_service.enable_job(job_id, enabled=False) is None: + return _http_error(404, "automation not found") + elif action == "delete": + result = self.cron_service.remove_job(job_id) + if result == "not_found": + return _http_error(404, "automation not found") + if result == "protected": + return _http_error(403, "system automation is protected") + elif action == "run": + if not job.enabled: + return _http_error(409, "automation is disabled") + task = asyncio.create_task(self.cron_service.run_job(job_id, force=False)) + task.add_done_callback(self._log_automation_run_result) + elif action == "update": + values = _automation_values_from_request(request) + if values is None: + return _http_error(400, "invalid automation update payload") + parsed = _parse_automation_update(values, current_job=job) + if isinstance(parsed, str): + return _http_error(400, parsed) + try: + result = self.cron_service.update_job(job_id, **parsed) + except ValueError as exc: + return _http_error(400, str(exc)) + if result == "not_found": + return _http_error(404, "automation not found") + if result == "protected": + return _http_error(403, "system automation is protected") + else: + return _http_error(404, "unknown automation action") + + return self._handle_webui_automations(request) + + @staticmethod + def _log_automation_run_result(task: asyncio.Task[bool]) -> None: + try: + ran = task.result() + except Exception: + logger.exception("WebUI automation run-now task failed") + return + if not ran: + logger.warning("WebUI automation run-now task did not execute") + # -- Media routes ------------------------------------------------------- def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None: @@ -662,5 +776,132 @@ class GatewayHTTPHandler: extra_headers=[("Cache-Control", cache)], ) + +def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None: + raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER) + if not raw: + return {} + try: + values = json.loads(raw) + except Exception: + try: + values = json.loads(unquote(raw)) + except Exception: + return None + return values if isinstance(values, dict) else None + + +def _parse_automation_update( + values: dict[str, Any], + *, + current_job: CronJob | None = None, +) -> dict[str, Any] | str: + update: dict[str, Any] = {} + if "name" in values: + raw_name = values.get("name") + if not isinstance(raw_name, str): + return "name must be a string" + name = raw_name.strip() + if not name: + return "name cannot be empty" + update["name"] = name + if "message" in values: + raw_message = values.get("message") + if not isinstance(raw_message, str): + return "message must be a string" + message = raw_message.strip() + if not message: + return "message cannot be empty" + update["message"] = message + if "schedule" in values: + raw_schedule = values.get("schedule") + if not isinstance(raw_schedule, dict): + return "schedule must be an object" + parsed_schedule = _parse_automation_schedule(raw_schedule) + if isinstance(parsed_schedule, str): + return parsed_schedule + if current_job is not None and _schedule_matches_job(parsed_schedule, current_job): + return update + schedule_error = _validate_automation_schedule(parsed_schedule) + if schedule_error: + return schedule_error + update["schedule"] = parsed_schedule + update["delete_after_run"] = parsed_schedule.kind == "at" + return update + + +def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str: + raw_kind = values.get("kind") + if not isinstance(raw_kind, str): + return "schedule kind must be a string" + kind = raw_kind.strip() + if kind == "every": + every_ms = _positive_int(values.get("every_ms")) + if every_ms is None: + return "every schedule requires positive every_ms" + return CronSchedule(kind="every", every_ms=every_ms) + if kind == "cron": + raw_expr = values.get("expr") + if not isinstance(raw_expr, str): + return "cron schedule requires expr" + expr = raw_expr.strip() + if not expr: + return "cron schedule requires expr" + raw_tz = values.get("tz") + if raw_tz is not None and not isinstance(raw_tz, str): + return "cron schedule timezone must be a string" + tz = raw_tz.strip() if isinstance(raw_tz, str) else "" + return CronSchedule(kind="cron", expr=expr, tz=tz or None) + if kind == "at": + at_ms = _positive_int(values.get("at_ms")) + if at_ms is None: + return "one-time schedule requires positive at_ms" + return CronSchedule(kind="at", at_ms=at_ms) + return "unknown schedule kind" + + +def _schedule_matches_job(schedule: CronSchedule, job: CronJob) -> bool: + current = job.schedule + if schedule.kind != current.kind: + return False + if schedule.kind == "at": + return schedule.at_ms == current.at_ms + if schedule.kind == "every": + return schedule.every_ms == current.every_ms + if schedule.kind == "cron": + return (schedule.expr or "") == (current.expr or "") and ( + schedule.tz or None + ) == (current.tz or None) + return False + + +def _validate_automation_schedule(schedule: CronSchedule) -> str | None: + if schedule.kind == "at": + if not schedule.at_ms or schedule.at_ms <= int(time.time() * 1000): + return "one-time schedule must be in the future" + return None + if schedule.kind != "cron": + return None + + try: + from datetime import datetime + from zoneinfo import ZoneInfo + + from croniter import croniter + + tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo + base = datetime.now(tz=tz) + croniter(schedule.expr, base).get_next(datetime) + except Exception: + return "cron schedule is invalid" + return None + + +def _positive_int(value: Any) -> int | None: + if isinstance(value, bool) or not isinstance(value, int): + return None + return value if value > 0 else None + + def _is_websocket_channel_session_key(key: str) -> bool: return key.startswith("websocket:") diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index b695665c..8fb80fcd 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -119,6 +119,42 @@ async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Re ) +@pytest.mark.asyncio +async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None: + class Conn: + remote_address = None + + def __init__(self) -> None: + self.sent: list[str] = [] + + async def send(self, raw: str) -> None: + self.sent.append(raw) + + channel = _ch(bus) + active_conn = Conn() + other_conn = Conn() + channel._attach(active_conn, "chat-a") + channel._attach(other_conn, "chat-b") + assert sorted(channel._subs) == ["chat-a", "chat-b"] + assert sum(len(conns) for conns in channel._subs.values()) == 2 + + await channel.send_session_updated("chat-a", scope="thread") + + active_events = [json.loads(raw)["event"] for raw in active_conn.sent] + other_events = [json.loads(raw)["event"] for raw in other_conn.sent] + + assert (active_events, other_events) == ( + ["session_updated"], + ["session_updated"], + ) + payload = json.loads(other_conn.sent[0]) + assert payload == { + "event": "session_updated", + "chat_id": "chat-a", + "scope": "thread", + } + + async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]: """Receive until a specific websocket event appears.""" for _ in range(10): @@ -128,6 +164,10 @@ async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]: raise AssertionError(f"websocket event {event!r} was not received") +def _sent_ws_payloads(mock_ws: AsyncMock) -> list[dict[str, Any]]: + return [json.loads(call.args[0]) for call in mock_ws.send.await_args_list] + + def test_normalize_http_path_strips_trailing_slash_except_root() -> None: assert _normalize_http_path("/chat/") == "/chat" assert _normalize_http_path("/chat?x=1") == "/chat" @@ -1234,9 +1274,10 @@ async def test_send_turn_end_emits_turn_end_event() -> None: metadata={"_turn_end": True}, )) - mock_ws.send.assert_awaited_once() - body = json.loads(mock_ws.send.await_args.args[0]) - assert body == {"event": "turn_end", "chat_id": "chat-1"} + assert _sent_ws_payloads(mock_ws) == [ + {"event": "turn_end", "chat_id": "chat-1"}, + {"event": "session_updated", "chat_id": "chat-1", "scope": "thread"}, + ] @pytest.mark.asyncio @@ -1253,9 +1294,10 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None: metadata={"_turn_end": True, "latency_ms": 1500}, )) - mock_ws.send.assert_awaited_once() - body = json.loads(mock_ws.send.await_args.args[0]) - assert body == {"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500} + assert _sent_ws_payloads(mock_ws) == [ + {"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500}, + {"event": "session_updated", "chat_id": "chat-1", "scope": "thread"}, + ] @pytest.mark.asyncio @@ -1273,9 +1315,10 @@ async def test_send_turn_end_includes_goal_state_when_present() -> None: metadata={"_turn_end": True, "goal_state": blob}, )) - mock_ws.send.assert_awaited_once() - body = json.loads(mock_ws.send.await_args.args[0]) - assert body == {"event": "turn_end", "chat_id": "chat-1", "goal_state": blob} + assert _sent_ws_payloads(mock_ws) == [ + {"event": "turn_end", "chat_id": "chat-1", "goal_state": blob}, + {"event": "session_updated", "chat_id": "chat-1", "scope": "thread"}, + ] @pytest.mark.asyncio diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index fe74666c..b23d1097 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -3,12 +3,14 @@ import asyncio import functools import json +import random +import socket import threading import time from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock -from urllib.parse import urlencode +from urllib.parse import quote, urlencode import httpx import pytest @@ -23,6 +25,18 @@ from nanobot.webui.gateway_services import GatewayServices, build_gateway_servic _PORT = 29900 +def _free_port() -> int: + for _ in range(100): + port = random.randint(30_000, 60_000) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("127.0.0.1", port)) + except OSError: + continue + return port + raise RuntimeError("could not find a free localhost port") + + def _make_handler( cfg: dict[str, Any] | WebSocketConfig, bus: Any, @@ -813,6 +827,255 @@ async def test_session_delete_removes_file( await server_task +@pytest.mark.asyncio +async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( + bus: MagicMock, tmp_path: Path +) -> None: + port = _free_port() + base_url = f"http://127.0.0.1:{port}" + cron = CronService(tmp_path / "cron" / "jobs.json") + user_job = cron.add_job( + name="Daily repo check", + schedule=CronSchedule(kind="every", every_ms=86_400_000), + message="Check the repo status", + session_key="websocket:abc", + origin_channel="websocket", + origin_chat_id="abc", + ) + incomplete_job = cron.add_job( + name="english-quiz", + schedule=CronSchedule(kind="every", every_ms=3_600_000), + message="Practice English", + session_key="unified:default", + ) + external_job = cron.add_job( + name="WeChat quiz", + schedule=CronSchedule(kind="every", every_ms=3_600_000), + message="Send a quiz", + session_key="weixin:wx-chat", + origin_channel="weixin", + origin_chat_id="wx-chat", + ) + past_one_shot_job = cron.add_job( + name="Past one-shot", + schedule=CronSchedule(kind="at", at_ms=1), + message="Old one-shot message", + session_key="websocket:abc", + origin_channel="websocket", + origin_chat_id="abc", + delete_after_run=True, + ) + cron.register_system_job( + CronJob( + id="heartbeat", + name="heartbeat", + schedule=CronSchedule(kind="every", every_ms=60_000), + payload=CronPayload(kind="system_event"), + ) + ) + session_manager = _seed_session(tmp_path, key="websocket:abc") + external_session = Session(key="weixin:wx-chat") + external_session.add_message("user", "Scheduled cron job triggered") + session_manager.save(external_session) + channel = _ch( + bus, + session_manager=session_manager, + cron_service=cron, + cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(), + port=port, + ) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + deny = await _http_get(f"{base_url}/api/webui/automations") + assert deny.status_code == 401, deny.text + + boot = await _http_get(f"{base_url}/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + resp = await _http_get( + f"{base_url}/api/webui/automations", + headers=auth, + ) + assert resp.status_code == 200 + assert "wx-chat" not in resp.text + assert "unified:default" not in resp.text + body = resp.json() + by_id = {job["id"]: job for job in body["jobs"]} + assert by_id[user_job.id]["protected"] is False + assert by_id[user_job.id]["state"]["pending"] is True + assert by_id[user_job.id]["state"]["run_history"] == [] + assert by_id[user_job.id]["origin"]["session_key"] == "websocket:abc" + assert by_id[user_job.id]["origin"]["preview"] == "hi" + assert "session_key" not in by_id[incomplete_job.id]["payload"] + assert "origin_channel" not in by_id[incomplete_job.id]["payload"] + assert "origin_chat_id" not in by_id[incomplete_job.id]["payload"] + assert by_id[incomplete_job.id]["origin"] is None + assert "session_key" not in by_id[external_job.id]["payload"] + assert "origin_channel" not in by_id[external_job.id]["payload"] + assert "origin_chat_id" not in by_id[external_job.id]["payload"] + assert by_id[external_job.id]["origin"]["channel"] == "weixin" + assert "session_key" not in by_id[external_job.id]["origin"] + assert "chat_id" not in by_id[external_job.id]["origin"] + assert by_id[external_job.id]["origin"]["preview"] == "" + assert by_id["heartbeat"]["protected"] is True + + updated = await _http_get( + f"{base_url}/api/webui/automations/update?id={user_job.id}", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps( + { + "name": "Daily quiz", + "message": "Ask the daily quiz", + "schedule": { + "kind": "cron", + "expr": "0 9 * * *", + "tz": "UTC", + }, + } + ), + }, + ) + assert updated.status_code == 200 + by_id = {job["id"]: job for job in updated.json()["jobs"]} + assert by_id[user_job.id]["name"] == "Daily quiz" + assert by_id[user_job.id]["payload"]["message"] == "Ask the daily quiz" + assert by_id[user_job.id]["schedule"]["kind"] == "cron" + assert by_id[user_job.id]["schedule"]["expr"] == "0 9 * * *" + assert by_id[user_job.id]["schedule"]["tz"] == "UTC" + + unicode_update = await _http_get( + f"{base_url}/api/webui/automations/update?id={user_job.id}", + headers={ + **auth, + "X-Nanobot-Automation-Values": quote( + json.dumps( + { + "name": "每日测验", + "message": "问今日测验", + }, + ensure_ascii=False, + ), + safe="", + ), + }, + ) + assert unicode_update.status_code == 200 + assert cron.get_job(user_job.id).name == "每日测验" + assert cron.get_job(user_job.id).payload.message == "问今日测验" + + malformed_update = await _http_get( + f"{base_url}/api/webui/automations/update?id={user_job.id}", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps({"message": ["bad"]}), + }, + ) + assert malformed_update.status_code == 400 + assert cron.get_job(user_job.id).payload.message == "问今日测验" + + invalid_cron_update = await _http_get( + f"{base_url}/api/webui/automations/update?id={user_job.id}", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps( + {"schedule": {"kind": "cron", "expr": "not a cron", "tz": "UTC"}} + ), + }, + ) + assert invalid_cron_update.status_code == 400 + assert cron.get_job(user_job.id).schedule.expr == "0 9 * * *" + + past_one_shot_update = await _http_get( + f"{base_url}/api/webui/automations/update?id={past_one_shot_job.id}", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps( + { + "message": "Updated one-shot message", + "schedule": {"kind": "at", "at_ms": 1}, + } + ), + }, + ) + assert past_one_shot_update.status_code == 200 + assert cron.get_job(past_one_shot_job.id).payload.message == "Updated one-shot message" + assert cron.get_job(past_one_shot_job.id).schedule.at_ms == 1 + + protected_update = await _http_get( + f"{base_url}/api/webui/automations/update?id=heartbeat", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps({"name": "bad"}), + }, + ) + assert protected_update.status_code == 403 + + disabled = await _http_get( + f"{base_url}/api/webui/automations/disable?id={user_job.id}", + headers=auth, + ) + assert disabled.status_code == 200 + by_id = {job["id"]: job for job in disabled.json()["jobs"]} + assert by_id[user_job.id]["enabled"] is False + + disabled_run = await _http_get( + f"{base_url}/api/webui/automations/run?id={user_job.id}", + headers=auth, + ) + assert disabled_run.status_code == 409 + + unbound_run = await _http_get( + f"{base_url}/api/webui/automations/run?id={incomplete_job.id}", + headers=auth, + ) + assert unbound_run.status_code == 409 + assert "no linked chat" in unbound_run.text + + unbound_enable = await _http_get( + f"{base_url}/api/webui/automations/enable?id={incomplete_job.id}", + headers=auth, + ) + assert unbound_enable.status_code == 409 + assert "no linked chat" in unbound_enable.text + + protected_delete = await _http_get( + f"{base_url}/api/webui/automations/delete?id=heartbeat", + headers=auth, + ) + assert protected_delete.status_code == 403 + protected_disable = await _http_get( + f"{base_url}/api/webui/automations/disable?id=heartbeat", + headers=auth, + ) + assert protected_disable.status_code == 403 + protected_run = await _http_get( + f"{base_url}/api/webui/automations/run?id=heartbeat", + headers=auth, + ) + assert protected_run.status_code == 403 + + enabled = await _http_get( + f"{base_url}/api/webui/automations/enable?id={user_job.id}", + headers=auth, + ) + assert enabled.status_code == 200 + by_id = {job["id"]: job for job in enabled.json()["jobs"]} + assert by_id[user_job.id]["enabled"] is True + + deleted = await _http_get( + f"{base_url}/api/webui/automations/delete?id={user_job.id}", + headers=auth, + ) + assert deleted.status_code == 200 + assert user_job.id not in {job["id"] for job in deleted.json()["jobs"]} + assert "heartbeat" in {job["id"] for job in deleted.json()["jobs"]} + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_session_delete_blocks_when_bound_automation_exists( bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index d81d4121..2072e063 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -17,6 +17,14 @@ async def _wait_until(predicate, *, timeout: float = 1.0, interval: float = 0.01 assert predicate() +def _bound_chat(chat_id: str = "chat-1") -> dict[str, str]: + return { + "session_key": f"websocket:{chat_id}", + "origin_channel": "websocket", + "origin_chat_id": chat_id, + } + + def test_add_job_rejects_unknown_timezone(tmp_path) -> None: service = CronService(tmp_path / "cron" / "jobs.json") @@ -37,12 +45,74 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None: name="tz ok", schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"), message="hello", + **_bound_chat(), ) assert job.schedule.tz == "America/Vancouver" assert job.state.next_run_at_ms is not None +@pytest.mark.asyncio +async def test_unbound_agent_jobs_are_disabled_on_add(tmp_path) -> None: + called: list[str] = [] + + async def on_job(job): + called.append(job.id) + + service = CronService( + tmp_path / "cron" / "jobs.json", + on_job=on_job, + ) + job = service.add_job( + name="unbound", + schedule=CronSchedule(kind="every", every_ms=60_000), + message="hello", + ) + + assert job.enabled is False + assert job.state.next_run_at_ms is None + assert job.state.last_status == "error" + assert "missing bound session delivery context" in (job.state.last_error or "") + assert await service.run_job(job.id, force=True) is False + assert called == [] + + +def test_unbound_agent_jobs_are_disabled_on_load(tmp_path) -> None: + store_path = tmp_path / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True) + store_path.write_text( + json.dumps( + { + "version": 1, + "jobs": [ + { + "id": "unbound-1", + "name": "Unbound reminder", + "enabled": True, + "schedule": {"kind": "every", "everyMs": 60_000}, + "payload": { + "kind": "agent_turn", + "message": "check status", + }, + "state": {"nextRunAtMs": 1}, + "createdAtMs": 1, + "updatedAtMs": 1, + } + ], + } + ), + encoding="utf-8", + ) + + job = CronService(store_path).get_job("unbound-1") + + assert job is not None + assert job.enabled is False + assert job.state.next_run_at_ms is None + assert job.state.last_status == "error" + assert "missing bound session delivery context" in (job.state.last_error or "") + + def test_add_job_migrates_legacy_delivery_context(tmp_path) -> None: service = CronService(tmp_path / "cron" / "jobs.json") meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}} @@ -263,6 +333,7 @@ async def test_execute_job_records_run_history(tmp_path) -> None: name="hist", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) await service.run_job(job.id) @@ -287,6 +358,7 @@ async def test_run_history_records_errors(tmp_path) -> None: name="fail", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) await service.run_job(job.id) @@ -308,6 +380,7 @@ async def test_run_history_records_skipped_jobs(tmp_path) -> None: name="skip", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) await service.run_job(job.id) @@ -332,7 +405,7 @@ async def test_run_history_records_job_cancellation(tmp_path) -> None: name="cancel", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", - session_key="websocket:chat-1", + **_bound_chat(), ) assert await service.run_job(job.id) is True @@ -355,6 +428,7 @@ async def test_run_history_trimmed_to_max(tmp_path) -> None: name="trim", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) for _ in range(25): await service.run_job(job.id) @@ -371,6 +445,7 @@ async def test_run_history_persisted_to_disk(tmp_path) -> None: name="persist", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) await service.run_job(job.id) @@ -395,6 +470,7 @@ async def test_run_job_disabled_does_not_flip_running_state(tmp_path) -> None: name="disabled", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) service.enable_job(job.id, enabled=False) @@ -413,6 +489,7 @@ async def test_run_job_preserves_running_service_state(tmp_path) -> None: name="manual", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) result = await service.run_job(job.id, force=True) @@ -435,6 +512,7 @@ async def test_running_service_honors_external_disable(tmp_path) -> None: name="external-disable", schedule=CronSchedule(kind="every", every_ms=200), message="hello", + **_bound_chat(), ) await service.start() try: @@ -483,6 +561,7 @@ async def test_start_server_not_jobs(tmp_path): name="hist", schedule=CronSchedule(kind="every", every_ms=100), message="hello", + **_bound_chat(), ) assert len(service.list_jobs()) == 1 await _wait_until(lambda: bool(called), timeout=0.8) @@ -503,6 +582,7 @@ async def test_subsecond_job_not_delayed_to_one_second(tmp_path): name="fast", schedule=CronSchedule(kind="every", every_ms=100), message="hello", + **_bound_chat(), ) await service.start() try: @@ -526,6 +606,7 @@ async def test_running_service_picks_up_external_add(tmp_path): name="heartbeat", schedule=CronSchedule(kind="every", every_ms=100), message="tick", + **_bound_chat("heartbeat"), ) await service.start() try: @@ -536,6 +617,7 @@ async def test_running_service_picks_up_external_add(tmp_path): name="external", schedule=CronSchedule(kind="every", every_ms=100), message="ping", + **_bound_chat("external"), ) await _wait_until(lambda: "external" in called, timeout=0.8) @@ -557,6 +639,7 @@ async def test_add_job_during_jobs_exec(tmp_path): name="test", schedule=CronSchedule(kind="every", every_ms=150), message="tick", + **_bound_chat("test"), ) run_once = False @@ -565,6 +648,7 @@ async def test_add_job_during_jobs_exec(tmp_path): name="heartbeat", schedule=CronSchedule(kind="every", every_ms=100), message="tick", + **_bound_chat("heartbeat"), ) assert len(service.list_jobs()) == 1 await service.start() @@ -585,6 +669,7 @@ async def test_external_update_preserves_run_history_records(tmp_path): name="history", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) await service.run_job(job.id, force=True) @@ -626,6 +711,7 @@ async def test_timer_execution_is_not_rolled_back_by_list_jobs_reload(tmp_path): name="race", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) job.state.next_run_at_ms = max(1, int(time.time() * 1000) - 1_000) service._save_store() @@ -650,6 +736,7 @@ def test_update_job_changes_name(tmp_path) -> None: name="old name", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) result = service.update_job(job.id, name="new name") assert isinstance(result, CronJob) @@ -663,6 +750,7 @@ def test_update_job_changes_schedule(tmp_path) -> None: name="sched", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) old_next = job.state.next_run_at_ms @@ -679,6 +767,7 @@ def test_update_job_changes_message(tmp_path) -> None: name="msg", schedule=CronSchedule(kind="every", every_ms=60_000), message="old message", + **_bound_chat(), ) result = service.update_job(job.id, message="new message") assert isinstance(result, CronJob) @@ -691,6 +780,7 @@ def test_update_job_changes_cron_expression(tmp_path) -> None: name="cron-job", schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"), message="hello", + **_bound_chat(), ) result = service.update_job( job.id, @@ -726,6 +816,7 @@ def test_update_job_validates_schedule(tmp_path) -> None: name="validate", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) with pytest.raises(ValueError, match="unknown timezone"): service.update_job( @@ -743,6 +834,7 @@ async def test_update_job_preserves_run_history(tmp_path) -> None: name="hist", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) await service.run_job(job.id) @@ -758,6 +850,7 @@ def test_update_job_offline_writes_action(tmp_path) -> None: name="offline", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", + **_bound_chat(), ) service.update_job(job.id, name="updated-offline") @@ -811,6 +904,7 @@ async def test_list_jobs_during_on_job_does_not_cause_stale_reload(tmp_path) -> name=name, schedule=CronSchedule(kind="every", every_ms=3_600_000), message="test", + **_bound_chat(name), ) # Force next_run to the past so _on_timer picks them up for job in service._store.jobs: diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index bcf518ed..d2099d45 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -20,6 +20,14 @@ def _make_tool_with_tz(tmp_path, tz: str) -> CronTool: return CronTool(service, default_timezone=tz) +def _bound_chat(chat_id: str = "chat-1") -> dict[str, str]: + return { + "session_key": f"websocket:{chat_id}", + "origin_channel": "websocket", + "origin_chat_id": chat_id, + } + + # -- _format_timing tests -- @@ -146,6 +154,7 @@ def test_list_cron_job_shows_expression_and_timezone(tmp_path) -> None: name="Morning scan", schedule=CronSchedule(kind="cron", expr="0 9 * * 1-5", tz="America/Denver"), message="scan", + **_bound_chat(), ) result = tool._list_jobs() assert "cron: 0 9 * * 1-5 (America/Denver)" in result @@ -157,6 +166,7 @@ def test_list_every_job_shows_human_interval(tmp_path) -> None: name="Frequent check", schedule=CronSchedule(kind="every", every_ms=1_800_000), message="check", + **_bound_chat(), ) result = tool._list_jobs() assert "every 30m" in result @@ -168,6 +178,7 @@ def test_list_every_job_hours(tmp_path) -> None: name="Hourly check", schedule=CronSchedule(kind="every", every_ms=7_200_000), message="check", + **_bound_chat(), ) result = tool._list_jobs() assert "every 2h" in result @@ -179,6 +190,7 @@ def test_list_every_job_seconds(tmp_path) -> None: name="Fast check", schedule=CronSchedule(kind="every", every_ms=30_000), message="check", + **_bound_chat(), ) result = tool._list_jobs() assert "every 30s" in result @@ -190,6 +202,7 @@ def test_list_every_job_non_minute_seconds(tmp_path) -> None: name="Ninety-second check", schedule=CronSchedule(kind="every", every_ms=90_000), message="check", + **_bound_chat(), ) result = tool._list_jobs() assert "every 90s" in result @@ -201,6 +214,7 @@ def test_list_every_job_milliseconds(tmp_path) -> None: name="Sub-second check", schedule=CronSchedule(kind="every", every_ms=200), message="check", + **_bound_chat(), ) result = tool._list_jobs() assert "every 200ms" in result @@ -212,6 +226,7 @@ def test_list_at_job_shows_iso_timestamp(tmp_path) -> None: name="One-shot", schedule=CronSchedule(kind="at", at_ms=1773684000000), message="fire", + **_bound_chat(), ) result = tool._list_jobs() assert "at 2026-" in result @@ -226,6 +241,7 @@ async def test_list_shows_last_run_state(tmp_path) -> None: name="Stateful job", schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"), message="test", + **_bound_chat(), ) # Simulate a completed run by updating state in the store job.state.last_run_at_ms = 1773673200000 @@ -245,6 +261,7 @@ async def test_list_shows_error_message(tmp_path) -> None: name="Failed job", schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"), message="test", + **_bound_chat(), ) job.state.last_run_at_ms = 1773673200000 job.state.last_status = "error" @@ -262,6 +279,7 @@ def test_list_shows_next_run(tmp_path) -> None: name="Upcoming job", schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"), message="test", + **_bound_chat(), ) result = tool._list_jobs() assert "Next run:" in result diff --git a/tests/webui/test_session_list_index.py b/tests/webui/test_session_list_index.py index c8b0adf6..cc10f8c1 100644 --- a/tests/webui/test_session_list_index.py +++ b/tests/webui/test_session_list_index.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +from datetime import datetime from pathlib import Path import nanobot.webui.session_list_index as session_list_index @@ -86,5 +88,86 @@ def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。" +def test_webui_session_list_uses_webui_transcript_activity_for_sort( + tmp_path: Path, + monkeypatch, +) -> None: + webui_dir = tmp_path / "webui" + webui_dir.mkdir() + monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir) + + manager = SessionManager(tmp_path) + old_session = manager.get_or_create("websocket:old-metadata") + old_session.created_at = datetime(2026, 6, 15, 10, 0, 0) + old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0) + old_session.add_message("user", "old metadata") + old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0) + manager.save(old_session) + + newer_metadata = manager.get_or_create("websocket:newer-metadata") + newer_metadata.created_at = datetime(2026, 6, 15, 11, 0, 0) + newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0) + newer_metadata.add_message("user", "newer metadata") + newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0) + manager.save(newer_metadata) + + transcript = webui_dir / "websocket_old-metadata.jsonl" + transcript.write_text( + '{"event":"turn_end","chat_id":"old-metadata"}\n', + encoding="utf-8", + ) + activity_ns = int(datetime(2026, 6, 15, 12, 0, 0).timestamp() * 1_000_000_000) + os.utime(transcript, ns=(activity_ns, activity_ns)) + + rows = list_webui_sessions(manager) + + assert [row["key"] for row in rows] == [ + "websocket:old-metadata", + "websocket:newer-metadata", + ] + assert rows[0]["updated_at"].startswith("2026-06-15T12:00:00") + + +def test_webui_session_list_rescans_when_transcript_changes( + tmp_path: Path, + monkeypatch, +) -> None: + webui_dir = tmp_path / "webui" + webui_dir.mkdir() + monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir) + + manager = SessionManager(tmp_path) + session = manager.get_or_create("websocket:transcript-change") + session.created_at = datetime(2026, 6, 15, 10, 0, 0) + session.updated_at = datetime(2026, 6, 15, 10, 0, 0) + session.add_message("user", "preview") + session.updated_at = datetime(2026, 6, 15, 10, 0, 0) + manager.save(session) + + assert list_webui_sessions(manager)[0]["preview"] == "preview" + + transcript = webui_dir / "websocket_transcript-change.jsonl" + transcript.write_text( + '{"event":"turn_end","chat_id":"transcript-change"}\n', + encoding="utf-8", + ) + activity_ns = int(datetime(2026, 6, 15, 12, 30, 0).timestamp() * 1_000_000_000) + os.utime(transcript, ns=(activity_ns, activity_ns)) + + original_scan = session_list_index._scan_session_row + scanned: list[str] = [] + + def record_scan(session_manager: SessionManager, path: Path) -> dict | None: + scanned.append(path.name) + return original_scan(session_manager, path) + + monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan) + + rows = list_webui_sessions(manager) + + assert scanned == [manager._get_session_path("websocket:transcript-change").name] + assert rows[0]["updated_at"].startswith("2026-06-15T12:30:00") + + def list_webui_sessions(manager: SessionManager) -> list[dict]: return session_list_index.list_webui_sessions(manager) diff --git a/webui/README.md b/webui/README.md index 2730a272..998ea02b 100644 --- a/webui/README.md +++ b/webui/README.md @@ -1,6 +1,10 @@ -# nanobot WebUI +# nanobot WebUI Source -The WebUI is the browser workbench served by `nanobot gateway`. If you installed `nanobot-ai` from PyPI, the WebUI bundle is already included; this `webui/` source tree is only needed when you are changing the frontend. +This directory contains the React/TypeScript source for the nanobot WebUI. If +you installed `nanobot-ai` from PyPI and only want to use the bundled browser UI, +read the user guide in [`docs/webui.md`](../docs/webui.md). You do not need +Node.js, Bun, Vite, or anything in this directory unless you are changing the +frontend. For the project overview, install guide, and general docs map, see the root [`README.md`](../README.md) and [`docs/README.md`](../docs/README.md). @@ -8,46 +12,14 @@ For the project overview, install guide, and general docs map, see the root [`RE | Goal | Start with | Opens at | |---|---|---| -| Use the bundled browser UI | [Just want to use the WebUI?](#just-want-to-use-the-webui) | `http://127.0.0.1:8765` | -| Use the WebUI from another device | [Access from another device (LAN)](#access-from-another-device-lan) | `http://:8765` | +| Use the bundled browser UI | [`docs/webui.md`](../docs/webui.md) | `http://127.0.0.1:8765` | +| Use the WebUI from another device | [`docs/webui.md#lan-access`](../docs/webui.md#lan-access) | `http://:8765` | | Change WebUI source code | [Develop the WebUI (Vite HMR)](#develop-the-webui-vite-hmr) | `http://127.0.0.1:5173` | | Debug setup failures | [`docs/troubleshooting.md#webui-problems`](../docs/troubleshooting.md#webui-problems) | Diagnosis order and common fixes | -## Just want to use the WebUI? - -If you installed nanobot via `python -m pip install nanobot-ai`, the WebUI is **already bundled** in the wheel. You do **not** need Node.js, Bun, Vite, or anything in this directory unless you are changing the WebUI source code. - -First prove the provider path: - -```bash -nanobot agent -m "Hello!" -``` - -If the shell cannot find `nanobot`, use the module form from the same Python environment: - -```bash -python -m nanobot agent -m "Hello!" -``` - -Then merge this WebSocket snippet into your existing `~/.nanobot/config.json` instead of replacing the whole file: - -```json -{ "channels": { "websocket": { "enabled": true } } } -``` - -If you are new to JSON snippets, see [`docs/start-without-technical-background.md#how-to-merge-json-snippets`](../docs/start-without-technical-background.md#how-to-merge-json-snippets). - -Start the gateway: - -```bash -nanobot gateway -``` - -Leave this terminal running while you use the WebUI. Closing it stops the browser UI and WebSocket connection. - -Open [`http://127.0.0.1:8765`](http://127.0.0.1:8765). The gateway's `18790` port is only the health endpoint, not the browser UI. For setup failures, use [`docs/troubleshooting.md`](../docs/troubleshooting.md#webui-problems). - -This `webui/` tree is for people **changing the WebUI source code**. It is built with Vite + React 18 + TypeScript + Tailwind 3 + shadcn/ui, talks to the gateway over the WebSocket multiplex protocol, and reads session metadata from the embedded REST surface on the same port. +The source app is built with Vite + React 18 + TypeScript + Tailwind 3 + +shadcn/ui. It talks to the gateway over the WebSocket multiplex protocol and +reads session metadata from the embedded REST surface on the same port. ## Layout @@ -104,27 +76,6 @@ If your gateway listens on a non-default port, point the dev server at it: NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev ``` -### Access from another device (LAN) - -To use the WebUI from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`: - -```json -{ - "channels": { - "websocket": { - "enabled": true, - "host": "0.0.0.0", - "port": 8765, - "tokenIssueSecret": "your-secret-here" - } - } -} -``` - -The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set. - -Then open `http://:8765` on the other device. The WebUI will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once. - ## Build for packaged runtime You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel. diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 722a6e55..ee035c2f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -65,14 +65,15 @@ type BootState = }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; -const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1"; +const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1"; +const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1"; const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const SIDEBAR_WIDTH = 272; const SIDEBAR_RAIL_WIDTH = 56; const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`; const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; -type ShellView = "chat" | "settings" | "apps" | "skills"; +type ShellView = "chat" | "settings" | "apps" | "automations" | "skills"; type ShellRoute = { view: ShellView; activeKey: string | null; @@ -87,6 +88,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [ "voice", "browser", "apps", + "automations", "skills", "runtime", "advanced", @@ -101,7 +103,7 @@ function defaultShellRoute(): ShellRoute { } function shellViewForSettingsSection(section: SettingsSectionKey): ShellView { - if (section === "apps" || section === "skills") return section; + if (section === "apps" || section === "automations" || section === "skills") return section; return "settings"; } @@ -130,6 +132,9 @@ function readShellRoute(): ShellRoute { if (path === "/apps") { return { view: "apps", activeKey, settingsSection: "apps" }; } + if (path === "/automations") { + return { view: "automations", activeKey, settingsSection: "automations" }; + } if (path === "/skills") { return { view: "skills", activeKey, settingsSection: "skills" }; } @@ -255,10 +260,12 @@ function readSidebarOpen(): boolean { } } -function readCompletedRunChatIds(): Set { +function readSessionUpdateChatIds(): Set { if (typeof window === "undefined") return new Set(); try { - const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY); + const raw = + window.localStorage.getItem(SESSION_UPDATES_STORAGE_KEY) + ?? window.localStorage.getItem(LEGACY_COMPLETED_RUNS_STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return new Set(); return new Set(parsed.filter((item): item is string => typeof item === "string")); @@ -267,10 +274,10 @@ function readCompletedRunChatIds(): Set { } } -function writeCompletedRunChatIds(chatIds: Set): void { +function writeSessionUpdateChatIds(chatIds: Set): void { try { window.localStorage.setItem( - COMPLETED_RUNS_STORAGE_KEY, + SESSION_UPDATES_STORAGE_KEY, JSON.stringify(Array.from(chatIds)), ); } catch { @@ -570,7 +577,7 @@ function Shell({ const [restartToast, setRestartToast] = useState(null); const [isRestarting, setIsRestarting] = useState(false); const [runningChatIds, setRunningChatIds] = useState>(() => new Set()); - const [completedChatIds, setCompletedChatIds] = useState>(readCompletedRunChatIds); + const [updatedChatIds, setUpdatedChatIds] = useState>(readSessionUpdateChatIds); const [workspaces, setWorkspaces] = useState(null); const skills = useSkills(token); const [settingsSnapshot, setSettingsSnapshot] = useState(null); @@ -638,20 +645,20 @@ function Shell({ }, [hostSidebarOpen]); useEffect(() => { - writeCompletedRunChatIds(completedChatIds); - }, [completedChatIds]); + writeSessionUpdateChatIds(updatedChatIds); + }, [updatedChatIds]); const activeSession = useMemo(() => { if (!activeKey) return null; return sessions.find((s) => s.key === activeKey) ?? null; }, [sessions, activeKey]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); - const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]); + const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const activeChatId = activeSession?.chatId ?? null; useEffect(() => { activeChatIdRef.current = activeChatId; if (!activeChatId) return; - setCompletedChatIds((current) => { + setUpdatedChatIds((current) => { if (!current.has(activeChatId)) return current; const next = new Set(current); next.delete(activeChatId); @@ -691,7 +698,7 @@ function Shell({ useEffect(() => { if (loading) return; const knownChatIds = new Set(sessions.map((session) => session.chatId)); - setCompletedChatIds((current) => { + setUpdatedChatIds((current) => { const next = new Set( Array.from(current).filter((chatId) => knownChatIds.has(chatId)), ); @@ -719,12 +726,25 @@ function Shell({ }, [activeKey, loading, navigate, sessions]); useEffect(() => { - return client.onSessionUpdate((_chatId, _scope, workspaceScope) => { + return client.onSessionUpdate((chatId, scope, workspaceScope) => { + if (scope === "thread") { + setUpdatedChatIds((current) => { + const next = new Set(current); + if (activeChatIdRef.current === chatId) { + next.delete(chatId); + } else { + next.add(chatId); + } + return next.size === current.size && next.has(chatId) === current.has(chatId) + ? current + : next; + }); + } if (!workspaceScope) return; const next = normalizeWorkspaceScope(workspaceScope); setWorkspaceOverrides((current) => ({ ...current, - [_chatId]: next, + [chatId]: next, })); setDraftWorkspaceScope(next); setWorkspaceError(null); @@ -761,7 +781,7 @@ function Shell({ runningChatIdsRef.current = next; return next; }); - setCompletedChatIds((current) => { + setUpdatedChatIds((current) => { let changed = false; const next = new Set(current); for (const chatId of activeRunIds) { @@ -958,7 +978,7 @@ function Shell({ const selected = sessions.find((session) => session.key === key); const selectedChatId = selected?.chatId; if (selectedChatId) { - setCompletedChatIds((current) => { + setUpdatedChatIds((current) => { if (!current.has(selectedChatId)) return current; const next = new Set(current); next.delete(selectedChatId); @@ -1166,6 +1186,12 @@ function Shell({ setMobileSidebarOpen(false); }, [activeKey, navigate]); + const onOpenAutomations = useCallback(() => { + setSessionSearchOpen(false); + navigate({ view: "automations", activeKey, settingsSection: "automations" }); + setMobileSidebarOpen(false); + }, [activeKey, navigate]); + const onOpenSkills = useCallback(() => { setSessionSearchOpen(false); navigate({ view: "skills", activeKey, settingsSection: "skills" }); @@ -1223,7 +1249,7 @@ function Shell({ nextRunning.add(chatId); runningChatIdsRef.current = nextRunning; setRunningChatIds(nextRunning); - setCompletedChatIds((current) => { + setUpdatedChatIds((current) => { if (!current.has(chatId)) return current; const next = new Set(current); next.delete(chatId); @@ -1237,7 +1263,7 @@ function Shell({ nextRunning.delete(chatId); runningChatIdsRef.current = nextRunning; setRunningChatIds(nextRunning); - setCompletedChatIds((current) => { + setUpdatedChatIds((current) => { const next = new Set(current); if (activeChatIdRef.current === chatId) { next.delete(chatId); @@ -1341,6 +1367,12 @@ function Shell({ }); return; } + if (view === "automations") { + document.title = t("app.documentTitle.chat", { + title: t("settings.nav.automations", { defaultValue: "Automations" }), + }); + return; + } if (view === "skills") { document.title = t("app.documentTitle.chat", { title: t("settings.nav.skills", { defaultValue: "Skills" }), @@ -1367,9 +1399,10 @@ function Shell({ onNewChatInProject, onOpenSettings, onOpenApps, + onOpenAutomations, onOpenSkills, onOpenSearch: onOpenSessionSearch, - activeUtility: view === "apps" || view === "skills" ? view : null, + activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null, onToggleArchived, pinnedKeys: sidebarState.pinned_keys, archivedKeys: sidebarState.archived_keys, @@ -1377,7 +1410,7 @@ function Shell({ projectNameOverrides: sidebarState.project_name_overrides, collapsedGroups: sidebarState.collapsed_groups, runningChatIds: runningChatIdList, - completedChatIds: completedChatIdList, + updatedChatIds: updatedChatIdList, viewState: sidebarState.view, showArchived: sidebarState.view.show_archived, archivedCount: sidebarState.archived_keys.length, diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index ccc44a45..889e1a10 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -60,7 +60,7 @@ interface ChatListProps { projectNameOverrides?: Record; collapsedGroups?: Record; runningChatIds?: string[]; - completedChatIds?: string[]; + updatedChatIds?: string[]; density?: SidebarDensity; showPreviews?: boolean; showTimestamps?: boolean; @@ -89,7 +89,7 @@ export const ChatList = memo(function ChatList({ projectNameOverrides = {}, collapsedGroups = {}, runningChatIds = [], - completedChatIds = [], + updatedChatIds = [], density = "comfortable", showPreviews = false, showTimestamps = false, @@ -175,7 +175,7 @@ export const ChatList = memo(function ChatList({ const pinned = new Set(pinnedKeys); const archived = new Set(archivedKeys); const running = new Set(runningChatIds); - const completed = new Set(completedChatIds); + const updated = new Set(updatedChatIds); const compact = density === "compact"; const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); @@ -245,8 +245,8 @@ export const ChatList = memo(function ChatList({ const projectMode = group.kind === "project"; const activityState = running.has(s.chatId) ? "running" - : completed.has(s.chatId) && !active - ? "complete" + : updated.has(s.chatId) && !active + ? "updated" : null; return (

  • @@ -525,7 +525,7 @@ function ChatsFoldFooter({ function SessionActivityIndicator({ state, }: { - state: "running" | "complete" | null; + state: "running" | "updated" | null; }) { const { t } = useTranslation(); @@ -542,15 +542,15 @@ function SessionActivityIndicator({ ); } - if (state === "complete") { - const label = t("chat.activity.complete"); + if (state === "updated") { + const label = t("chat.activity.updated"); return ( - + ); } diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index d0a5ff77..5b578b05 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -80,16 +80,16 @@ export function DeleteConfirm({ ) : null} - + {t("deleteConfirm.cancel")} {hasAutomations ? t("deleteConfirm.confirmWithAutomations") diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index f50275b3..66a8bd41 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react"; import { Archive, Brain, + CalendarClock, Menu, Search, Settings, @@ -36,8 +37,9 @@ interface SidebarProps { onOpenSettings: () => void; onOpenApps: () => void; onOpenSkills: () => void; + onOpenAutomations: () => void; onOpenSearch: () => void; - activeUtility?: "apps" | "skills" | null; + activeUtility?: "apps" | "skills" | "automations" | null; onToggleArchived: () => void; onCollapse: () => void; onExpand?: () => void; @@ -49,7 +51,7 @@ interface SidebarProps { projectNameOverrides?: Record; collapsedGroups?: Record; runningChatIds?: string[]; - completedChatIds?: string[]; + updatedChatIds?: string[]; viewState?: SidebarViewState; showArchived?: boolean; archivedCount?: number; @@ -166,6 +168,13 @@ export function Sidebar(props: SidebarProps) { active={props.activeUtility === "skills"} icon={} /> + } + /> {props.archivedCount ? ( (() => initialSettings); const [cliApps, setCliApps] = useState(null); const [mcpPresets, setMcpPresets] = useState(null); + const [automations, setAutomations] = useState(null); const [loading, setLoading] = useState(() => initialSettings === null); const [cliAppsLoading, setCliAppsLoading] = useState(true); const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); + const [automationsLoading, setAutomationsLoading] = useState(false); const [saving, setSaving] = useState(false); const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false); const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false); @@ -533,12 +549,21 @@ export function SettingsView({ const [expandedProvider, setExpandedProvider] = useState(null); const [providerQuery, setProviderQuery] = useState(""); const [appsQuery, setAppsQuery] = useState(""); + const [automationsQuery, setAutomationsQuery] = useState(""); + const [automationsFilter, setAutomationsFilter] = useState("all"); + const [automationsSort, setAutomationsSort] = useState("next"); const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); const [appsKindFilter, setAppsKindFilter] = useState("all"); const [mcpMessage, setMcpMessage] = useState(null); const [mcpError, setMcpError] = useState(null); + const [automationsError, setAutomationsError] = useState(null); + const [automationAction, setAutomationAction] = useState(null); + const [automationPendingDelete, setAutomationPendingDelete] = + useState(null); + const [automationPendingEdit, setAutomationPendingEdit] = + useState(null); const [mcpFieldValues, setMcpFieldValues] = useState>>({}); const [customMcpForm, setCustomMcpForm] = useState(DEFAULT_CUSTOM_MCP_FORM); const [mcpConfigImport, setMcpConfigImport] = useState(""); @@ -701,6 +726,54 @@ export function SettingsView({ }; }, [activeSection, token]); + const refreshAutomations = useCallback( + async (showLoading = false) => { + if (showLoading) setAutomationsLoading(true); + try { + const payload = await fetchAutomations(token); + setAutomations(payload); + setAutomationsError(null); + } catch (err) { + setAutomationsError((err as Error).message); + } finally { + if (showLoading) setAutomationsLoading(false); + } + }, + [token], + ); + + useEffect(() => { + if (activeSection !== "automations") return; + let cancelled = false; + const refresh = async (showLoading = false) => { + if (cancelled) return; + if (showLoading) setAutomationsLoading(true); + try { + const payload = await fetchAutomations(token); + if (cancelled) return; + setAutomations(payload); + setAutomationsError(null); + } catch (err) { + if (!cancelled) setAutomationsError((err as Error).message); + } finally { + if (!cancelled && showLoading) setAutomationsLoading(false); + } + }; + void refresh(true); + const interval = window.setInterval(() => void refresh(false), 5000); + const refreshOnFocus = () => { + if (document.visibilityState !== "hidden") void refresh(false); + }; + window.addEventListener("focus", refreshOnFocus); + document.addEventListener("visibilitychange", refreshOnFocus); + return () => { + cancelled = true; + window.clearInterval(interval); + window.removeEventListener("focus", refreshOnFocus); + document.removeEventListener("visibilitychange", refreshOnFocus); + }; + }, [activeSection, token]); + useEffect(() => { try { window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs)); @@ -1225,6 +1298,46 @@ export function SettingsView({ } }; + const handleAutomationAction = async ( + action: AutomationAction, + job: SessionAutomationJob, + ) => { + const key = `${action}:${job.id}`; + setAutomationAction(key); + setAutomationsError(null); + try { + const payload = await runAutomationAction(token, action, job.id); + setAutomations(payload); + if (action === "delete") setAutomationPendingDelete(null); + if (action === "run") { + window.setTimeout(() => void refreshAutomations(false), 1200); + window.setTimeout(() => void refreshAutomations(false), 4000); + } + } catch (err) { + setAutomationsError((err as Error).message); + } finally { + setAutomationAction(null); + } + }; + + const handleAutomationEdit = async ( + job: SessionAutomationJob, + values: AutomationUpdatePayload, + ) => { + const key = `update:${job.id}`; + setAutomationAction(key); + setAutomationsError(null); + try { + const payload = await updateAutomation(token, job.id, values); + setAutomations(payload); + setAutomationPendingEdit(null); + } catch (err) { + setAutomationsError((err as Error).message); + } finally { + setAutomationAction(null); + } + }; + const handleMcpPresetAction = async ( action: "enable" | "remove" | "test", name: string, @@ -1505,6 +1618,24 @@ export function SettingsView({ isRestarting={isRestarting || hostEngineApplying} /> ); + case "automations": + return ( + + ); case "skills": return ; case "runtime": @@ -1541,7 +1672,14 @@ export function SettingsView({ }; return ( -
    +
    {showSidebar ? ( + { + if (!open) setAutomationPendingDelete(null); + }} + onConfirm={(job) => handleAutomationAction("delete", job)} + /> + + { + if (!open) setAutomationPendingEdit(null); + }} + onSave={handleAutomationEdit} + /> +
    ) : null} -

    - {t("settings.sidebar.title")} -

    + {showSidebar ? ( +

    + {t("settings.sidebar.title")} +

    + ) : null}

    {text(`settings.nav.${activeSection}`, titleForSection(activeSection))}

    @@ -3250,6 +3408,1381 @@ function WebSettings({ ); } +function AutomationsSettings({ + payload, + loading, + query, + filter, + sort, + actionKey, + error, + onQueryChange, + onFilterChange, + onSortChange, + onAction, + onRequestEdit, + onRequestDelete, +}: { + payload: AutomationsPayload | null; + loading: boolean; + query: string; + filter: AutomationFilter; + sort: AutomationSort; + actionKey: string | null; + error: string | null; + onQueryChange: (value: string) => void; + onFilterChange: (value: AutomationFilter) => void; + onSortChange: (value: AutomationSort) => void; + onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; + onRequestEdit: (job: SessionAutomationJob) => void; + onRequestDelete: (job: SessionAutomationJob) => void; +}) { + const { t, i18n } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const jobs = payload?.jobs ?? []; + const locale = i18n.resolvedLanguage || i18n.language; + const [selectedJobId, setSelectedJobId] = useState(null); + const filtered = useMemo(() => { + const searchTokens = parseAutomationSearchQuery(query); + return sortAutomationJobs(jobs, sort) + .filter((job) => automationMatchesFilter(job, filter)) + .filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens)); + }, [filter, jobs, query, sort]); + const activeCount = jobs.filter((job) => { + const key = automationStatusKey(job); + return key === "active" || key === "running"; + }).length; + const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length; + const failedCount = jobs.filter(automationNeedsAttention).length; + const systemCount = jobs.filter((job) => job.protected).length; + const summaryOptions: Array<{ value: AutomationFilter; label: string; count: number }> = [ + { value: "all", label: tx("settings.automations.filters.all", "All"), count: jobs.length }, + { value: "active", label: tx("settings.automations.filters.active", "Active"), count: activeCount }, + { value: "paused", label: tx("settings.automations.filters.paused", "Paused"), count: pausedCount }, + { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention"), count: failedCount }, + { value: "system", label: tx("settings.automations.filters.system", "System"), count: systemCount }, + ]; + const sortLabel = { + next: tx("settings.automations.sort.next", "Next run"), + last: tx("settings.automations.sort.last", "Last run"), + updated: tx("settings.automations.sort.updated", "Updated"), + name: tx("settings.automations.sort.name", "Name"), + } satisfies Record; + const selectedJob = filtered.find((job) => job.id === selectedJobId) ?? filtered[0] ?? null; + + useEffect(() => { + if (!filtered.length) { + if (selectedJobId !== null) setSelectedJobId(null); + return; + } + if (!selectedJobId || !filtered.some((job) => job.id === selectedJobId)) { + setSelectedJobId(filtered[0].id); + } + }, [filtered, selectedJobId]); + + return ( +
    +
    +
    +
    +
    + {summaryOptions.map((option) => ( + + ))} +
    +
    + +
    +
    + + onQueryChange(event.target.value)} + placeholder={tx( + "settings.automations.search", + "Search task, message, linked chat, or schedule", + )} + className="h-9 w-full rounded-[13px] border-border/45 bg-background/85 pl-9 text-[13px] shadow-[0_8px_22px_rgba(15,23,42,0.04)] dark:border-white/10 dark:bg-background/40" + /> +
    + + + + + + {(Object.keys(sortLabel) as AutomationSort[]).map((value) => ( + onSortChange(value)}> + {sortLabel[value]} + {sort === value ? : null} + + ))} + + +
    +
    +
    + + {error ? ( +
    + + {error} +
    + ) : null} + + {loading && !payload ? ( +
    + + {tx("settings.automations.loading", "Loading automations...")} +
    + ) : filtered.length && selectedJob ? ( +
    + + +
    + ) : ( +
    +
    + {jobs.length + ? tx("settings.automations.noMatches", "No automations match this view.") + : tx("settings.automations.empty", "No automations yet.")} +
    + {!jobs.length ? ( +
    + {tx( + "settings.automations.emptyHint", + "Create one from where it should run so nanobot keeps the right context.", + )} +
    + ) : null} +
    + )} +
    + ); +} + +function AutomationListItem({ + job, + locale, + selected, + onSelect, +}: { + job: SessionAutomationJob; + locale: string; + selected: boolean; + onSelect: () => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const status = automationStatus(job, tx); + const origin = automationOriginLabel(job, tx); + const nextRun = formatAutomationNext(job, tx); + + return ( +
    + +
    + ); +} + +function AutomationDetailPanel({ + job, + locale, + actionKey, + onAction, + onRequestEdit, + onRequestDelete, +}: { + job: SessionAutomationJob; + locale: string; + actionKey: string | null; + onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; + onRequestEdit: (job: SessionAutomationJob) => void; + onRequestDelete: (job: SessionAutomationJob) => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const status = automationStatus(job, tx); + const origin = automationOriginLabel(job, tx); + const originHref = job.origin?.channel === "websocket" && job.origin.session_key + ? `#/chat/${encodeURIComponent(job.origin.session_key)}` + : null; + const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null; + const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null; + const message = job.payload.message || tx("settings.automations.systemTask", "System-managed automation"); + const schedule = formatAutomationSchedule(job, locale, tx); + const [messageExpanded, setMessageExpanded] = useState(false); + const messageNeedsExpansion = automationMessageNeedsExpansion(message); + + useEffect(() => { + setMessageExpanded(false); + }, [job.id]); + + return ( +
    +
    +
    +
    +
    +

    + {job.name || job.id} +

    + {status.label} + {job.delete_after_run ? ( + {tx("settings.automations.oneShot", "One-time")} + ) : null} +
    +

    + {schedule} · {origin} +

    +
    + +
    +
    + +
    +
    +
    +
    + {tx("settings.automations.fields.message", "Message")} +
    +
    + {message} +
    + {messageNeedsExpansion ? ( + + ) : null} +
    + +
    + + {formatAutomationNext(job, tx)} + + + {originHref ? ( + + {origin} + + + ) : ( + origin + )} + +
    + + {job.state.last_error ? ( +
    + {job.state.last_error} +
    + ) : null} +
    + + +
    +
    + ); +} + +function AutomationActionGroup({ + job, + actionKey, + onAction, + onRequestEdit, + onRequestDelete, +}: { + job: SessionAutomationJob; + actionKey: string | null; + onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; + onRequestEdit: (job: SessionAutomationJob) => void; + onRequestDelete: (job: SessionAutomationJob) => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const canManage = !job.protected; + const hasLinkedChat = Boolean(job.origin); + const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending; + const toggleAction: AutomationAction = job.enabled ? "disable" : "enable"; + const canToggle = canManage && (job.enabled || hasLinkedChat); + const toggleBusy = actionKey === `${toggleAction}:${job.id}`; + + if (!canManage) { + return ( + + {tx("settings.automations.protected", "Protected")} + + ); + } + + return ( +
    + onRequestEdit(job)} + > + + + void onAction("run", job)} + > + + + void onAction(toggleAction, job)} + > + {job.enabled ? ( + + ) : ( + + )} + + onRequestDelete(job)} + > + + +
    + ); +} + +function AutomationStatusBadge({ + tone = "neutral", + children, +}: { + tone?: "neutral" | "success" | "warning"; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function automationMessageNeedsExpansion(message: string): boolean { + return message.length > 360 || message.split(/\r?\n/).length > 6; +} + +function AutomationDetail({ + label, + title, + secondary, + children, +}: { + label: string; + title?: string; + secondary?: ReactNode; + children: ReactNode; +}) { + return ( +
    +
    + {label} +
    +
    +
    + {children} +
    + {secondary ? ( +
    + {secondary} +
    + ) : null} +
    +
    + ); +} + +type AutomationEveryUnit = "second" | "minute" | "hour" | "day"; + +type AutomationEditDraft = { + name: string; + message: string; + scheduleKind: "at" | "every" | "cron"; + everyValue: string; + everyUnit: AutomationEveryUnit; + cronExpr: string; + tz: string; + atLocal: string; +}; +type AutomationScheduleUpdate = NonNullable; + +const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [ + { value: "second", ms: 1000 }, + { value: "minute", ms: 60_000 }, + { value: "hour", ms: 3_600_000 }, + { value: "day", ms: 86_400_000 }, +]; + +function AutomationEditDialog({ + job, + saving, + onOpenChange, + onSave, +}: { + job: SessionAutomationJob | null; + saving: boolean; + onOpenChange: (open: boolean) => void; + onSave: (job: SessionAutomationJob, values: AutomationUpdatePayload) => void | Promise; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const [draft, setDraft] = useState(() => automationDraftFromJob(null)); + + useEffect(() => { + setDraft(automationDraftFromJob(job)); + }, [job]); + + const validation = automationEditDraftError(draft, job, tx); + const scheduleOptions = [ + { value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") }, + { value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") }, + { value: "at", label: tx("settings.automations.scheduleTypes.at", "Once") }, + ]; + const unitLabels: Record = { + second: tx("settings.automations.everyUnits.second", "Seconds"), + minute: tx("settings.automations.everyUnits.minute", "Minutes"), + hour: tx("settings.automations.everyUnits.hour", "Hours"), + day: tx("settings.automations.everyUnits.day", "Days"), + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + const payload = automationUpdatePayloadFromDraft(draft, job); + if (!job || typeof payload === "string") return; + void onSave(job, payload); + }; + + return ( + + {job ? ( + +
    + + {tx("settings.automations.editTitle", "Edit automation")} + + +
    + + +