Merge PR #4330: feat(webui): add automation management view

feat(webui): add automation management view
This commit is contained in:
Xubin Ren
2026-06-17 01:55:39 +08:00
committed by GitHub
40 changed files with 4548 additions and 168 deletions
+3 -3
View File
@@ -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).
<p align="center">
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
@@ -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
+3 -3
View File
@@ -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 |
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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) |
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+168
View File
@@ -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://<your-ip>: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).
+3 -2
View File
@@ -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}
+54 -1
View File
@@ -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)
+123 -4
View File
@@ -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
+70 -4
View File
@@ -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)
+243 -2
View File
@@ -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:")
+52 -9
View File
@@ -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
+264 -1
View File
@@ -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
+95 -1
View File
@@ -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:
+18
View File
@@ -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
+83
View File
@@ -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)
+11 -60
View File
@@ -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://<your-ip>: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://<your-ip>: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://<your-ip>: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.
+54 -21
View File
@@ -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<string> {
function readSessionUpdateChatIds(): Set<string> {
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<string> {
}
}
function writeCompletedRunChatIds(chatIds: Set<string>): void {
function writeSessionUpdateChatIds(chatIds: Set<string>): 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<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const skills = useSkills(token);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
@@ -638,20 +645,20 @@ function Shell({
}, [hostSidebarOpen]);
useEffect(() => {
writeCompletedRunChatIds(completedChatIds);
}, [completedChatIds]);
writeSessionUpdateChatIds(updatedChatIds);
}, [updatedChatIds]);
const activeSession = useMemo<ChatSummary | null>(() => {
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,
+9 -9
View File
@@ -60,7 +60,7 @@ interface ChatListProps {
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
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 (
<li key={s.key} className="min-w-0">
@@ -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 (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" />
<span className="h-2 w-2 rounded-full bg-[#ff8a3d] shadow-[0_0_0_2px_rgba(255,138,61,0.16)]" />
</span>
);
}
+3 -3
View File
@@ -80,16 +80,16 @@ export function DeleteConfirm({
</div>
) : null}
</AlertDialogHeader>
<AlertDialogFooter className="mt-7 grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2">
<AlertDialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<AlertDialogCancel
onClick={onCancel}
className="mt-0 h-11 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
className="mt-0 h-11 w-full min-w-0 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{t("deleteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="h-11 rounded-full bg-destructive px-5 text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
className="h-11 w-full min-w-0 !whitespace-normal rounded-full bg-destructive px-5 text-center text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
>
{hasAutomations
? t("deleteConfirm.confirmWithAutomations")
+12 -3
View File
@@ -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<string, string>;
collapsedGroups?: Record<string, boolean>;
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={<Brain className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.automations", { defaultValue: "Automations" })}
onClick={props.onOpenAutomations}
active={props.activeUtility === "automations"}
icon={<CalendarClock className="h-4 w-4" />}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}
@@ -201,7 +210,7 @@ export function Sidebar(props: SidebarProps) {
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
updatedChatIds={props.updatedChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
File diff suppressed because it is too large Load Diff
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Change language"
},
"apps": "Apps",
"automations": "Automations",
"skills": {
"title": "Skills"
}
@@ -80,6 +81,7 @@
"runtime": "System",
"advanced": "Security",
"apps": "Apps",
"automations": "Automations",
"skills": "Skills"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Loading Apps...",
"empty": "No apps match this filter."
},
"automations": {
"filters": {
"all": "All",
"active": "Active",
"paused": "Paused",
"failed": "Needs attention",
"system": "System"
},
"sort": {
"next": "Next run",
"last": "Last run",
"updated": "Updated",
"name": "Name"
},
"search": "Search task, message, linked chat, or schedule",
"queue": "Queue",
"loading": "Loading automations...",
"noMatches": "No automations match this view.",
"empty": "No automations yet.",
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
"oneShot": "One-time",
"systemTask": "System-managed automation",
"labels": {
"schedule": "Schedule",
"next": "Next",
"origin": "Linked chat",
"created": "Created",
"updated": "Updated"
},
"runNow": "Run now",
"pause": "Pause",
"resume": "Resume",
"edit": "Edit",
"delete": "Delete",
"protected": "Protected",
"editTitle": "Edit automation",
"save": "Save",
"deleteTitle": "Delete automation",
"deleteDescription": "This removes {{name}} from the cron store. Past chat messages stay in the session.",
"cancel": "Cancel",
"status": {
"system": "System",
"running": "Running now",
"paused": "Paused",
"failed": "Failed",
"completed": "Completed",
"noSchedule": "No schedule",
"active": "Active"
},
"origin": {
"system": "System",
"unknown": "No linked chat"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "At {{time}}",
"every": "Every {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Daily at {{time}}",
"weekdaysAt": "Weekdays at {{time}}",
"hourlyAt": "Hourly at :{{minute}}",
"hourlyWindow": "Hourly {{start}}-{{end}} at :{{minute}}",
"custom": "Custom schedule"
},
"next": {
"paused": "Paused",
"pending": "Running now",
"none": "No next run"
},
"message": {
"showMore": "Show full message",
"showLess": "Show less"
},
"fields": {
"name": "Name",
"message": "Message",
"scheduleType": "Schedule type",
"every": "Every",
"unit": "Unit",
"cronExpression": "Cron expression",
"timezone": "Timezone",
"runAt": "Run at"
},
"scheduleTypes": {
"every": "Interval",
"cron": "Cron",
"at": "Once"
},
"everyUnits": {
"second": "Seconds",
"minute": "Minutes",
"hour": "Hours",
"day": "Days"
},
"validation": {
"nameRequired": "Name is required.",
"messageRequired": "Message is required.",
"intervalRequired": "Interval must be a positive number.",
"cronRequired": "Cron expression is required.",
"timeRequired": "Run time is required.",
"futureRequired": "Run time must be in the future."
}
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
@@ -527,7 +650,8 @@
"newInProject": "Start a new chat in {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Delete",
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
"moreAutomations": "+ {{count}} more",
"confirmWithAutomations": "Delete chat and automations",
"confirmWithAutomations": "Delete",
"schedule": {
"at": "{{time}}",
"every": "Every {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Cambiar idioma"
},
"apps": "Apps",
"automations": "Automatizaciones",
"skills": {
"title": "Habilidades"
}
@@ -80,6 +81,7 @@
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Aplicaciones",
"automations": "Automatizaciones",
"skills": "Habilidades"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro."
},
"automations": {
"filters": {
"all": "Todas",
"active": "Activas",
"paused": "Pausadas",
"failed": "Requieren atención",
"system": "Sistema"
},
"sort": {
"next": "Próxima ejecución",
"last": "Última ejecución",
"updated": "Actualizada",
"name": "Nombre"
},
"search": "Buscar tarea, mensaje, chat vinculado u horario",
"queue": "Cola",
"loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
"empty": "Aún no hay automatizaciones.",
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
"oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema",
"labels": {
"schedule": "Programación",
"next": "Siguiente",
"origin": "Chat vinculado",
"created": "Creada",
"updated": "Actualizada"
},
"runNow": "Ejecutar ahora",
"pause": "Pausar",
"resume": "Reanudar",
"edit": "Editar",
"delete": "Eliminar",
"protected": "Protegida",
"editTitle": "Editar automatización",
"save": "Guardar",
"deleteTitle": "Eliminar automatización",
"deleteDescription": "Esto elimina {{name}} del almacén cron. Los mensajes de chat anteriores permanecen en la sesión.",
"cancel": "Cancelar",
"status": {
"system": "Sistema",
"running": "Ejecutándose ahora",
"paused": "Pausada",
"failed": "Fallida",
"completed": "Completada",
"noSchedule": "Sin programación",
"active": "Activa"
},
"origin": {
"system": "Sistema",
"unknown": "Sin chat vinculado"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "A las {{time}}",
"every": "Cada {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Diaria a las {{time}}",
"weekdaysAt": "Días laborables a las {{time}}",
"hourlyAt": "Cada hora en :{{minute}}",
"hourlyWindow": "Cada hora {{start}}-{{end}} en :{{minute}}",
"custom": "Programación personalizada"
},
"next": {
"paused": "Pausada",
"pending": "Ejecutándose ahora",
"none": "Sin próxima ejecución"
},
"message": {
"showMore": "Mostrar mensaje completo",
"showLess": "Mostrar menos"
},
"fields": {
"name": "Nombre",
"message": "Mensaje",
"scheduleType": "Tipo de programación",
"every": "Cada",
"unit": "Unidad",
"cronExpression": "Expresión cron",
"timezone": "Zona horaria",
"runAt": "Ejecutar a las"
},
"scheduleTypes": {
"every": "Intervalo",
"cron": "Cron",
"at": "Una vez"
},
"everyUnits": {
"second": "Segundos",
"minute": "Minutos",
"hour": "Horas",
"day": "Días"
},
"validation": {
"nameRequired": "El nombre es obligatorio.",
"messageRequired": "El mensaje es obligatorio.",
"intervalRequired": "El intervalo debe ser un número positivo.",
"cronRequired": "La expresión cron es obligatoria.",
"timeRequired": "La hora de ejecución es obligatoria.",
"futureRequired": "La hora de ejecución debe estar en el futuro."
}
},
"oauth": {
"authentication": "Autenticación OAuth",
"signIn": "Iniciar sesión",
@@ -527,7 +650,8 @@
"newInProject": "Iniciar un chat nuevo en {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Eliminar",
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
"moreAutomations": "+ {{count}} más",
"confirmWithAutomations": "Eliminar chat y automatizaciones",
"confirmWithAutomations": "Eliminar",
"schedule": {
"at": "{{time}}",
"every": "Cada {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Changer de langue"
},
"apps": "Apps",
"automations": "Automatisations",
"skills": {
"title": "Compétences"
}
@@ -80,6 +81,7 @@
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Applications",
"automations": "Automatisations",
"skills": "Compétences"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond."
},
"automations": {
"filters": {
"all": "Toutes",
"active": "Actives",
"paused": "En pause",
"failed": "À traiter",
"system": "Système"
},
"sort": {
"next": "Prochaine exécution",
"last": "Dernière exécution",
"updated": "Mise à jour",
"name": "Nom"
},
"search": "Rechercher tâche, message, discussion liée ou planning",
"queue": "File",
"loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.",
"empty": "Aucune automatisation pour le moment.",
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
"oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système",
"labels": {
"schedule": "Planning",
"next": "Prochaine",
"origin": "Discussion liée",
"created": "Créée",
"updated": "Modifiée"
},
"runNow": "Exécuter maintenant",
"pause": "Mettre en pause",
"resume": "Reprendre",
"edit": "Modifier",
"delete": "Supprimer",
"protected": "Protégée",
"editTitle": "Modifier lautomatisation",
"save": "Enregistrer",
"deleteTitle": "Supprimer lautomatisation",
"deleteDescription": "Cela supprime {{name}} du stockage cron. Les anciens messages de chat restent dans la session.",
"cancel": "Annuler",
"status": {
"system": "Système",
"running": "En cours dexécution",
"paused": "En pause",
"failed": "Échouée",
"completed": "Terminée",
"noSchedule": "Aucun planning",
"active": "En cours"
},
"origin": {
"system": "Système",
"unknown": "Aucune discussion liée"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "À {{time}}",
"every": "Toutes les {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Chaque jour à {{time}}",
"weekdaysAt": "Jours ouvrés à {{time}}",
"hourlyAt": "Toutes les heures à :{{minute}}",
"hourlyWindow": "Toutes les heures {{start}}-{{end}} à :{{minute}}",
"custom": "Planning personnalisé"
},
"next": {
"paused": "En pause",
"pending": "En cours dexécution",
"none": "Aucune prochaine exécution"
},
"message": {
"showMore": "Afficher le message complet",
"showLess": "Afficher moins"
},
"fields": {
"name": "Nom",
"message": "Message",
"scheduleType": "Type de planning",
"every": "Toutes les",
"unit": "Unité",
"cronExpression": "Expression cron",
"timezone": "Fuseau horaire",
"runAt": "Exécuter à"
},
"scheduleTypes": {
"every": "Intervalle",
"cron": "Cron",
"at": "Une fois"
},
"everyUnits": {
"second": "Secondes",
"minute": "Minutes",
"hour": "Heures",
"day": "Jours"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"messageRequired": "Le message est obligatoire.",
"intervalRequired": "Lintervalle doit être un nombre positif.",
"cronRequired": "Lexpression cron est obligatoire.",
"timeRequired": "Lheure dexécution est obligatoire.",
"futureRequired": "Lheure dexécution doit être dans le futur."
}
},
"oauth": {
"authentication": "Authentification OAuth",
"signIn": "Se connecter",
@@ -527,7 +650,8 @@
"newInProject": "Démarrer une nouvelle discussion dans {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Supprimer",
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
"moreAutomations": "+ {{count}} autres",
"confirmWithAutomations": "Supprimer la discussion et les automatisations",
"confirmWithAutomations": "Supprimer",
"schedule": {
"at": "{{time}}",
"every": "Tous les {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Ganti bahasa"
},
"apps": "Aplikasi",
"automations": "Otomasi",
"skills": {
"title": "Skill"
}
@@ -80,6 +81,7 @@
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi",
"automations": "Otomasi",
"skills": "Skill"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok."
},
"automations": {
"filters": {
"all": "Semua",
"active": "Aktif",
"paused": "Dijeda",
"failed": "Perlu ditangani",
"system": "Sistem"
},
"sort": {
"next": "Jalankan berikutnya",
"last": "Jalankan terakhir",
"updated": "Diperbarui",
"name": "Nama"
},
"search": "Cari tugas, pesan, chat terkait, atau jadwal",
"queue": "Antrean",
"loading": "Memuat otomasi...",
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
"empty": "Belum ada otomasi.",
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
"oneShot": "Satu kali",
"systemTask": "Automasi yang dikelola sistem",
"labels": {
"schedule": "Jadwal",
"next": "Berikutnya",
"origin": "Chat tertaut",
"created": "Dibuat",
"updated": "Diperbarui"
},
"runNow": "Jalankan sekarang",
"pause": "Jeda",
"resume": "Lanjutkan",
"edit": "Edit",
"delete": "Hapus",
"protected": "Terlindungi",
"editTitle": "Edit otomasi",
"save": "Simpan",
"deleteTitle": "Hapus otomasi",
"deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.",
"cancel": "Batal",
"status": {
"system": "Sistem",
"running": "Sedang berjalan",
"paused": "Dijeda",
"failed": "Gagal",
"completed": "Selesai",
"noSchedule": "Tanpa jadwal",
"active": "Aktif"
},
"origin": {
"system": "Sistem",
"unknown": "Tidak ada chat tertaut"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "Pada {{time}}",
"every": "Setiap {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Setiap hari pukul {{time}}",
"weekdaysAt": "Hari kerja pukul {{time}}",
"hourlyAt": "Setiap jam pada :{{minute}}",
"hourlyWindow": "Setiap jam {{start}}-{{end}} pada :{{minute}}",
"custom": "Jadwal khusus"
},
"next": {
"paused": "Dijeda",
"pending": "Sedang berjalan",
"none": "Tidak ada jadwal berikutnya"
},
"message": {
"showMore": "Tampilkan pesan lengkap",
"showLess": "Tampilkan lebih sedikit"
},
"fields": {
"name": "Nama",
"message": "Pesan",
"scheduleType": "Jenis jadwal",
"every": "Setiap",
"unit": "Unit",
"cronExpression": "Ekspresi cron",
"timezone": "Zona waktu",
"runAt": "Jalankan pada"
},
"scheduleTypes": {
"every": "Interval",
"cron": "Cron",
"at": "Sekali"
},
"everyUnits": {
"second": "Detik",
"minute": "Menit",
"hour": "Jam",
"day": "Hari"
},
"validation": {
"nameRequired": "Nama wajib diisi.",
"messageRequired": "Pesan wajib diisi.",
"intervalRequired": "Interval harus berupa angka positif.",
"cronRequired": "Ekspresi cron wajib diisi.",
"timeRequired": "Waktu eksekusi wajib diisi.",
"futureRequired": "Waktu eksekusi harus berada di masa depan."
}
},
"oauth": {
"authentication": "Autentikasi OAuth",
"signIn": "Masuk",
@@ -527,7 +650,8 @@
"newInProject": "Mulai obrolan baru di {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Hapus",
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
"moreAutomations": "+ {{count}} lagi",
"confirmWithAutomations": "Hapus obrolan dan automasi",
"confirmWithAutomations": "Hapus",
"schedule": {
"at": "{{time}}",
"every": "Setiap {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "言語を変更"
},
"apps": "アプリ",
"automations": "自動タスク",
"skills": {
"title": "スキル"
}
@@ -80,6 +81,7 @@
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ",
"automations": "自動タスク",
"skills": "スキル"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "アプリを読み込み中...",
"empty": "一致するアプリはありません。"
},
"automations": {
"filters": {
"all": "すべて",
"active": "実行中",
"paused": "一時停止",
"failed": "要対応",
"system": "システム"
},
"sort": {
"next": "次回実行",
"last": "前回実行",
"updated": "更新日時",
"name": "名前"
},
"search": "タスク、メッセージ、関連チャット、予定を検索",
"queue": "キュー",
"loading": "自動タスクを読み込み中...",
"noMatches": "この表示に一致する自動タスクはありません。",
"empty": "自動タスクはまだありません。",
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
"oneShot": "一回限り",
"systemTask": "システム管理の自動タスク",
"labels": {
"schedule": "スケジュール",
"next": "次回",
"origin": "関連チャット",
"created": "作成",
"updated": "更新"
},
"runNow": "今すぐ実行",
"pause": "一時停止",
"resume": "再開",
"edit": "編集",
"delete": "削除",
"protected": "保護済み",
"editTitle": "自動タスクを編集",
"save": "保存",
"deleteTitle": "自動タスクを削除",
"deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。",
"cancel": "キャンセル",
"status": {
"system": "システム",
"running": "実行中",
"paused": "一時停止",
"failed": "失敗",
"completed": "完了",
"noSchedule": "スケジュールなし",
"active": "実行中"
},
"origin": {
"system": "システム",
"unknown": "関連チャットなし"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "{{time}}",
"every": "{{duration}} ごと",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "毎日 {{time}}",
"weekdaysAt": "平日 {{time}}",
"hourlyAt": "毎時 :{{minute}}",
"hourlyWindow": "{{start}}-{{end}} の毎時 :{{minute}}",
"custom": "カスタムスケジュール"
},
"next": {
"paused": "一時停止",
"pending": "実行中",
"none": "次回実行なし"
},
"message": {
"showMore": "メッセージ全文を表示",
"showLess": "折りたたむ"
},
"fields": {
"name": "名前",
"message": "メッセージ",
"scheduleType": "スケジュール種別",
"every": "間隔",
"unit": "単位",
"cronExpression": "Cron 式",
"timezone": "タイムゾーン",
"runAt": "実行日時"
},
"scheduleTypes": {
"every": "間隔",
"cron": "Cron",
"at": "一回限り"
},
"everyUnits": {
"second": "秒",
"minute": "分",
"hour": "時間",
"day": "日"
},
"validation": {
"nameRequired": "名前は必須です。",
"messageRequired": "メッセージは必須です。",
"intervalRequired": "間隔は正の数で指定してください。",
"cronRequired": "Cron 式は必須です。",
"timeRequired": "実行日時は必須です。",
"futureRequired": "実行日時は現在より後にしてください。"
}
},
"oauth": {
"authentication": "OAuth 認証",
"signIn": "サインイン",
@@ -527,7 +650,8 @@
"newInProject": "「{{project}}」で新しいチャットを開始",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "削除",
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
"moreAutomations": "他 {{count}} 件",
"confirmWithAutomations": "チャットと自動タスクを削除",
"confirmWithAutomations": "削除",
"schedule": {
"at": "{{time}}",
"every": "{{duration}} ごと",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "언어 변경"
},
"apps": "앱",
"automations": "자동화",
"skills": {
"title": "스킬"
}
@@ -80,6 +81,7 @@
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱",
"automations": "자동화",
"skills": "스킬"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "앱을 불러오는 중...",
"empty": "일치하는 앱이 없습니다."
},
"automations": {
"filters": {
"all": "전체",
"active": "활성",
"paused": "일시 중지",
"failed": "확인 필요",
"system": "시스템"
},
"sort": {
"next": "다음 실행",
"last": "마지막 실행",
"updated": "업데이트",
"name": "이름"
},
"search": "작업, 메시지, 연결된 채팅 또는 일정 검색",
"queue": "대기열",
"loading": "자동화를 불러오는 중...",
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
"empty": "아직 자동화가 없습니다.",
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
"oneShot": "일회성",
"systemTask": "시스템 관리 자동화",
"labels": {
"schedule": "일정",
"next": "다음",
"origin": "연결된 채팅",
"created": "생성",
"updated": "업데이트"
},
"runNow": "지금 실행",
"pause": "일시 중지",
"resume": "재개",
"edit": "편집",
"delete": "삭제",
"protected": "보호됨",
"editTitle": "자동화 편집",
"save": "저장",
"deleteTitle": "자동화 삭제",
"deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.",
"cancel": "취소",
"status": {
"system": "시스템",
"running": "실행 중",
"paused": "일시 중지",
"failed": "실패",
"completed": "완료",
"noSchedule": "일정 없음",
"active": "활성"
},
"origin": {
"system": "시스템",
"unknown": "연결된 채팅 없음"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "{{time}}",
"every": "{{duration}}마다",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "매일 {{time}}",
"weekdaysAt": "평일 {{time}}",
"hourlyAt": "매시간 :{{minute}}",
"hourlyWindow": "{{start}}-{{end}} 사이 매시간 :{{minute}}",
"custom": "사용자 지정 일정"
},
"next": {
"paused": "일시 중지",
"pending": "실행 중",
"none": "다음 실행 없음"
},
"message": {
"showMore": "전체 메시지 보기",
"showLess": "접기"
},
"fields": {
"name": "이름",
"message": "메시지",
"scheduleType": "일정 유형",
"every": "간격",
"unit": "단위",
"cronExpression": "Cron 식",
"timezone": "시간대",
"runAt": "실행 시간"
},
"scheduleTypes": {
"every": "간격",
"cron": "Cron",
"at": "일회성"
},
"everyUnits": {
"second": "초",
"minute": "분",
"hour": "시간",
"day": "일"
},
"validation": {
"nameRequired": "이름은 필수입니다.",
"messageRequired": "메시지는 필수입니다.",
"intervalRequired": "간격은 양수여야 합니다.",
"cronRequired": "Cron 식은 필수입니다.",
"timeRequired": "실행 시간은 필수입니다.",
"futureRequired": "실행 시간은 현재보다 이후여야 합니다."
}
},
"oauth": {
"authentication": "OAuth 인증",
"signIn": "로그인",
@@ -527,7 +650,8 @@
"newInProject": "{{project}}에서 새 채팅 시작",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "삭제",
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
"moreAutomations": "+ {{count}}개 더",
"confirmWithAutomations": "채팅과 자동화 삭제",
"confirmWithAutomations": "삭제",
"schedule": {
"at": "{{time}}",
"every": "{{duration}}마다",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Đổi ngôn ngữ"
},
"apps": "Ứng dụng",
"automations": "Tự động hóa",
"skills": {
"title": "Kỹ năng"
}
@@ -80,6 +81,7 @@
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng",
"automations": "Tự động hóa",
"skills": "Kỹ năng"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng phù hợp."
},
"automations": {
"filters": {
"all": "Tất cả",
"active": "Đang chạy",
"paused": "Đã tạm dừng",
"failed": "Cần xử lý",
"system": "Hệ thống"
},
"sort": {
"next": "Lần chạy tiếp theo",
"last": "Lần chạy trước",
"updated": "Đã cập nhật",
"name": "Tên"
},
"search": "Tìm tác vụ, tin nhắn, cuộc trò chuyện liên kết hoặc lịch",
"queue": "Hàng đợi",
"loading": "Đang tải tự động hóa...",
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
"empty": "Chưa có tự động hóa.",
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
"oneShot": "Một lần",
"systemTask": "Tự động hóa do hệ thống quản lý",
"labels": {
"schedule": "Lịch",
"next": "Tiếp theo",
"origin": "Cuộc trò chuyện liên kết",
"created": "Đã tạo",
"updated": "Đã cập nhật"
},
"runNow": "Chạy ngay",
"pause": "Tạm dừng",
"resume": "Tiếp tục",
"edit": "Sửa",
"delete": "Xóa",
"protected": "Được bảo vệ",
"editTitle": "Sửa tự động hóa",
"save": "Lưu",
"deleteTitle": "Xóa tự động hóa",
"deleteDescription": "Thao tác này xóa {{name}} khỏi kho cron. Tin nhắn chat trước đó vẫn ở trong phiên.",
"cancel": "Hủy",
"status": {
"system": "Hệ thống",
"running": "Đang chạy",
"paused": "Đã tạm dừng",
"failed": "Thất bại",
"completed": "Hoàn tất",
"noSchedule": "Không có lịch",
"active": "Đang chạy"
},
"origin": {
"system": "Hệ thống",
"unknown": "Chưa liên kết cuộc trò chuyện"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "Vào {{time}}",
"every": "Mỗi {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Hằng ngày lúc {{time}}",
"weekdaysAt": "Ngày làm việc lúc {{time}}",
"hourlyAt": "Mỗi giờ tại :{{minute}}",
"hourlyWindow": "Mỗi giờ {{start}}-{{end}} tại :{{minute}}",
"custom": "Lịch tùy chỉnh"
},
"next": {
"paused": "Đã tạm dừng",
"pending": "Đang chạy",
"none": "Không có lần chạy tiếp theo"
},
"message": {
"showMore": "Hiển thị toàn bộ tin nhắn",
"showLess": "Thu gọn"
},
"fields": {
"name": "Tên",
"message": "Tin nhắn",
"scheduleType": "Loại lịch",
"every": "Mỗi",
"unit": "Đơn vị",
"cronExpression": "Biểu thức cron",
"timezone": "Múi giờ",
"runAt": "Chạy lúc"
},
"scheduleTypes": {
"every": "Khoảng lặp",
"cron": "Cron",
"at": "Một lần"
},
"everyUnits": {
"second": "Giây",
"minute": "Phút",
"hour": "Giờ",
"day": "Ngày"
},
"validation": {
"nameRequired": "Tên là bắt buộc.",
"messageRequired": "Tin nhắn là bắt buộc.",
"intervalRequired": "Khoảng lặp phải là số dương.",
"cronRequired": "Biểu thức cron là bắt buộc.",
"timeRequired": "Thời gian chạy là bắt buộc.",
"futureRequired": "Thời gian chạy phải ở tương lai."
}
},
"oauth": {
"authentication": "Xác thực OAuth",
"signIn": "Đăng nhập",
@@ -527,7 +650,8 @@
"newInProject": "Bắt đầu cuộc trò chuyện mới trong {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Xóa",
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
"moreAutomations": "+ {{count}} mục nữa",
"confirmWithAutomations": "Xóa trò chuyện và tự động hóa",
"confirmWithAutomations": "Xóa",
"schedule": {
"at": "{{time}}",
"every": "Mỗi {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "切换语言"
},
"apps": "应用",
"automations": "自动任务",
"skills": {
"title": "技能"
}
@@ -80,6 +81,7 @@
"runtime": "系统",
"advanced": "安全",
"apps": "应用",
"automations": "自动任务",
"skills": "技能"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "正在加载应用...",
"empty": "没有匹配的应用。"
},
"automations": {
"filters": {
"all": "全部",
"active": "运行中",
"paused": "已暂停",
"failed": "异常",
"system": "系统"
},
"sort": {
"next": "下次运行",
"last": "上次运行",
"updated": "更新时间",
"name": "名称"
},
"search": "搜索任务、消息、关联会话或计划",
"queue": "任务队列",
"loading": "正在加载自动任务...",
"noMatches": "当前视图没有匹配的自动任务。",
"empty": "暂无自动任务。",
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
"oneShot": "一次性",
"systemTask": "系统管理的自动任务",
"labels": {
"schedule": "计划",
"next": "下次",
"origin": "关联会话",
"created": "创建于",
"updated": "更新于"
},
"runNow": "立即运行",
"pause": "暂停",
"resume": "恢复",
"edit": "编辑",
"delete": "删除",
"protected": "受保护",
"editTitle": "编辑自动任务",
"save": "保存",
"deleteTitle": "删除自动任务",
"deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。",
"cancel": "取消",
"status": {
"system": "系统",
"running": "正在运行",
"paused": "已暂停",
"failed": "失败",
"completed": "已完成",
"noSchedule": "无计划",
"active": "运行中"
},
"origin": {
"system": "系统",
"unknown": "未关联会话"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "钉钉",
"discord": "Discord",
"email": "邮件",
"feishu": "飞书",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "微信",
"wecom": "企业微信",
"weixin": "微信",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "在 {{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "每天 {{time}}",
"weekdaysAt": "工作日 {{time}}",
"hourlyAt": "每小时第 {{minute}} 分钟",
"hourlyWindow": "{{start}}-{{end}} 点每小时第 {{minute}} 分钟",
"custom": "自定义计划"
},
"next": {
"paused": "已暂停",
"pending": "正在运行",
"none": "没有下次运行"
},
"message": {
"showMore": "查看完整消息",
"showLess": "收起消息"
},
"fields": {
"name": "名称",
"message": "消息",
"scheduleType": "计划类型",
"every": "每隔",
"unit": "单位",
"cronExpression": "Cron 表达式",
"timezone": "时区",
"runAt": "运行时间"
},
"scheduleTypes": {
"every": "间隔",
"cron": "Cron",
"at": "一次性"
},
"everyUnits": {
"second": "秒",
"minute": "分钟",
"hour": "小时",
"day": "天"
},
"validation": {
"nameRequired": "名称不能为空。",
"messageRequired": "消息不能为空。",
"intervalRequired": "间隔必须是正整数。",
"cronRequired": "Cron 表达式不能为空。",
"timeRequired": "运行时间不能为空。",
"futureRequired": "运行时间必须晚于当前时间。"
}
},
"oauth": {
"authentication": "OAuth 认证",
"signIn": "登录",
@@ -527,7 +650,8 @@
"newInProject": "在 {{project}} 中开始新对话",
"activity": {
"running": "Agent 正在运行",
"complete": "Agent 已完成"
"complete": "Agent 已完成",
"updated": "有新内容"
},
"pin": "置顶",
"unpin": "取消置顶",
@@ -562,7 +686,7 @@
"confirm": "删除",
"automationsDescription": "这个对话有关联的自动任务。删除对话也会删除这些自动任务。",
"moreAutomations": "另有 {{count}} 个",
"confirmWithAutomations": "删除对话和自动任务",
"confirmWithAutomations": "删除",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "切換語言"
},
"apps": "應用",
"automations": "自動任務",
"skills": {
"title": "技能"
}
@@ -80,6 +81,7 @@
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用",
"automations": "自動任務",
"skills": "技能"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "正在載入應用...",
"empty": "沒有符合的應用。"
},
"automations": {
"filters": {
"all": "全部",
"active": "執行中",
"paused": "已暫停",
"failed": "異常",
"system": "系統"
},
"sort": {
"next": "下次執行",
"last": "上次執行",
"updated": "更新時間",
"name": "名稱"
},
"search": "搜尋任務、訊息、關聯對話或排程",
"queue": "任務佇列",
"loading": "正在載入自動任務...",
"noMatches": "目前檢視沒有符合的自動任務。",
"empty": "尚無自動任務。",
"emptyHint": "請從它應該執行的來源處建立,這樣 nanobot 才能保留正確上下文。",
"oneShot": "一次性",
"systemTask": "系統管理的自動任務",
"labels": {
"schedule": "排程",
"next": "下次",
"origin": "關聯會話",
"created": "建立於",
"updated": "更新於"
},
"runNow": "立即執行",
"pause": "暫停",
"resume": "恢復",
"edit": "編輯",
"delete": "刪除",
"protected": "受保護",
"editTitle": "編輯自動任務",
"save": "儲存",
"deleteTitle": "刪除自動任務",
"deleteDescription": "這會從 cron 儲存中刪除 {{name}},歷史聊天訊息會保留在會話中。",
"cancel": "取消",
"status": {
"system": "系統",
"running": "正在執行",
"paused": "已暫停",
"failed": "失敗",
"completed": "已完成",
"noSchedule": "無排程",
"active": "執行中"
},
"origin": {
"system": "系統",
"unknown": "未關聯會話"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "釘釘",
"discord": "Discord",
"email": "電子郵件",
"feishu": "飛書",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "微信",
"wecom": "企業微信",
"weixin": "微信",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "於 {{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "每天 {{time}}",
"weekdaysAt": "工作日 {{time}}",
"hourlyAt": "每小時第 {{minute}} 分鐘",
"hourlyWindow": "{{start}}-{{end}} 點每小時第 {{minute}} 分鐘",
"custom": "自訂排程"
},
"next": {
"paused": "已暫停",
"pending": "正在執行",
"none": "沒有下次執行"
},
"message": {
"showMore": "查看完整訊息",
"showLess": "收起訊息"
},
"fields": {
"name": "名稱",
"message": "訊息",
"scheduleType": "排程類型",
"every": "每隔",
"unit": "單位",
"cronExpression": "Cron 表達式",
"timezone": "時區",
"runAt": "執行時間"
},
"scheduleTypes": {
"every": "間隔",
"cron": "Cron",
"at": "一次性"
},
"everyUnits": {
"second": "秒",
"minute": "分鐘",
"hour": "小時",
"day": "天"
},
"validation": {
"nameRequired": "名稱不能為空。",
"messageRequired": "訊息不能為空。",
"intervalRequired": "間隔必須是正整數。",
"cronRequired": "Cron 表達式不能為空。",
"timeRequired": "執行時間不能為空。",
"futureRequired": "執行時間必須晚於目前時間。"
}
},
"oauth": {
"authentication": "OAuth 認證",
"signIn": "登入",
@@ -527,7 +650,8 @@
"newInProject": "在 {{project}} 中開始新對話",
"activity": {
"running": "Agent 正在執行",
"complete": "Agent 已完成"
"complete": "Agent 已完成",
"updated": "有新內容"
},
"pin": "置頂",
"unpin": "取消置頂",
@@ -562,7 +686,7 @@
"confirm": "刪除",
"automationsDescription": "這個對話有關聯的自動任務。刪除對話也會刪除這些自動任務。",
"moreAutomations": "另有 {{count}} 個",
"confirmWithAutomations": "刪除對話和自動任務",
"confirmWithAutomations": "刪除",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
+52
View File
@@ -1,4 +1,6 @@
import type {
AutomationsPayload,
AutomationUpdatePayload,
ChatSummary,
CliAppsPayload,
FilePreviewPayload,
@@ -87,6 +89,10 @@ function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefin
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
}
function automationValuesHeader(values: AutomationUpdatePayload): HeadersInit {
return { "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)) };
}
function splitKey(key: string): { channel: string; chatId: string } {
const idx = key.indexOf(":");
if (idx === -1) return { channel: "", chatId: key };
@@ -184,6 +190,52 @@ export async function fetchSessionAutomations(
);
}
export async function fetchAutomations(
token: string,
base: string = "",
): Promise<AutomationsPayload> {
return request<AutomationsPayload>(
`${base}/api/webui/automations`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function runAutomationAction(
token: string,
action: "enable" | "disable" | "delete" | "run",
id: string,
base: string = "",
): Promise<AutomationsPayload> {
const query = new URLSearchParams();
query.set("id", id);
return request<AutomationsPayload>(
`${base}/api/webui/automations/${action}?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function updateAutomation(
token: string,
id: string,
values: AutomationUpdatePayload,
base: string = "",
): Promise<AutomationsPayload> {
const query = new URLSearchParams();
query.set("id", id);
return request<AutomationsPayload>(
`${base}/api/webui/automations/update?${query}`,
token,
{
headers: automationValuesHeader(values),
},
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkills(
token: string,
base: string = "",
+32
View File
@@ -100,6 +100,10 @@ export interface SessionAutomationJob {
id: string;
name: string;
enabled: boolean;
protected?: boolean;
delete_after_run?: boolean;
created_at_ms?: number | null;
updated_at_ms?: number | null;
schedule: {
kind: "at" | "every" | "cron" | string;
at_ms?: number | null;
@@ -109,15 +113,43 @@ export interface SessionAutomationJob {
};
payload: {
message: string;
kind?: "agent_turn" | "system_event" | string;
};
state: {
next_run_at_ms?: number | null;
last_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null;
last_error?: string | null;
pending?: boolean;
run_history?: Array<{
run_at_ms: number;
status: "ok" | "error" | "skipped" | string;
duration_ms?: number;
error?: string | null;
}>;
};
origin?: {
session_key?: string;
channel: string;
chat_id?: string;
title?: string;
preview?: string;
} | null;
}
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface AutomationsPayload { jobs: SessionAutomationJob[]; }
export interface AutomationUpdatePayload {
name?: string;
message?: string;
schedule?: {
kind: "at" | "every" | "cron";
at_ms?: number;
every_ms?: number;
expr?: string;
tz?: string;
};
}
export interface SessionDeleteResult {
deleted: boolean;
+46
View File
@@ -4,6 +4,7 @@ import {
createModelConfiguration,
deleteSession,
fetchFilePreview,
fetchAutomations,
fetchCliApps,
fetchInstalledCliApps,
fetchMcpPresets,
@@ -20,9 +21,11 @@ import {
listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
runAutomationAction,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
updateAutomation,
updateSidebarState,
updateImageGenerationSettings,
updateModelConfiguration,
@@ -99,6 +102,49 @@ describe("webui API helpers", () => {
);
});
it("fetches workspace automations", async () => {
await fetchAutomations("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes workspace automation actions", async () => {
await runAutomationAction("tok", "disable", "job 1/2");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/disable?id=job+1%2F2",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes workspace automation updates", async () => {
const values = {
name: "每日测验",
message: "Ask 今日 quiz",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
} as const;
await updateAutomation("tok", "job 1/2", values);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=job+1%2F2",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
},
}),
);
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
});
it("fetches the WebUI skill summary", async () => {
await fetchSkills("tok");
+398 -9
View File
@@ -13,6 +13,7 @@ const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
@@ -194,7 +195,10 @@ vi.mock("@/lib/nanobot-client", () => {
onRuntimeModelUpdate = () => () => {};
onError = () => () => {};
onChat = () => () => {};
onSessionUpdate = () => () => {};
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(handler);
};
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
runStatusHandlers.add(handler);
return () => runStatusHandlers.delete(handler);
@@ -227,10 +231,12 @@ describe("App layout", () => {
toggleThemeSpy.mockReset();
attachSpy.mockReset();
runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
setNavigatorPlatform("Linux x86_64");
localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
ws_path: "/",
@@ -265,6 +271,23 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
});
it("places Automations after Skills in the main sidebar", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
const skillsButton = within(sidebar).getByRole("button", { name: "Skills" });
const automationsButton = within(sidebar).getByRole("button", { name: "Automations" });
expect(appsButton.compareDocumentPosition(skillsButton) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
expect(
skillsButton.compareDocumentPosition(automationsButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("opens Skills from the main sidebar", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
@@ -334,6 +357,331 @@ describe("App layout", () => {
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
});
it("opens Automations from the main sidebar", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": {
jobs: [
{
id: "job-1",
name: "Daily repo check",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: {
message: "Check the repo status",
kind: "agent_turn",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0),
last_status: "ok",
pending: false,
run_history: [],
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "Release prep",
preview: "Check release blockers",
},
},
{
id: "external-quiz",
name: "WeChat quiz",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "cron", expr: "30 9-23 * * *", tz: "Asia/Shanghai" },
payload: {
message: "Send a quiz",
kind: "agent_turn",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 17, 11, 30, 0),
last_status: "ok",
pending: false,
run_history: [],
},
origin: {
channel: "weixin",
title: "",
preview: "",
},
},
{
id: "heartbeat",
name: "heartbeat",
enabled: true,
protected: true,
schedule: { kind: "every", every_ms: 60_000 },
payload: { message: "", kind: "system_event" },
state: { next_run_at_ms: null, pending: false, run_history: [] },
origin: null,
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const automationsButton = within(sidebar).getByRole("button", {
name: "Automations",
});
fireEvent.click(automationsButton);
const heading = await screen.findByRole("heading", { name: "Automations" });
expect(heading).toBeInTheDocument();
const automationsMain = heading.closest("main");
expect(automationsMain).not.toBeNull();
expect(within(automationsMain as HTMLElement).queryByText("Settings")).not.toBeInTheDocument();
expect(screen.getAllByText("Daily repo check").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Check the repo status").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Release prep").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("WeChat quiz")).toBeInTheDocument();
expect(screen.getByText("WeChat")).toBeInTheDocument();
expect(screen.queryByText("weixin:wx-chat")).not.toBeInTheDocument();
expect(screen.queryByText("memory with dream state")).not.toBeInTheDocument();
expect(screen.getByText("heartbeat")).toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Automations" })).toHaveAttribute(
"aria-current",
"page",
);
expect(document.title).toBe("Automations · nanobot");
const searchInput = within(automationsMain as HTMLElement).getByPlaceholderText(
"Search task, message, linked chat, or schedule",
);
fireEvent.change(searchInput, { target: { value: "WeChat" } });
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
fireEvent.change(searchInput, { target: { value: "09-23" } });
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
});
it("edits a past one-time automation without resubmitting its old schedule", async () => {
const pastOneShot = {
id: "past-one-shot",
name: "Past one-shot",
enabled: true,
protected: false,
delete_after_run: true,
schedule: { kind: "at", at_ms: 1 },
payload: {
message: "Old one-shot message",
kind: "agent_turn",
},
state: {
next_run_at_ms: null,
last_status: "ok",
pending: false,
run_history: [],
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "Release prep",
preview: "Check release blockers",
},
};
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": { jobs: [pastOneShot] },
"/api/webui/automations/update?id=past-one-shot": {
jobs: [
{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" }));
expect((await screen.findAllByText("Past one-shot")).length).toBeGreaterThanOrEqual(1);
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
expect(screen.queryByText("Run time must be in the future.")).not.toBeInTheDocument();
expect(
screen.queryByText("Update the prompt and schedule. The linked chat stays unchanged."),
).not.toBeInTheDocument();
expect(screen.getByDisplayValue("Old one-shot message")).toHaveClass(
"min-h-[160px]",
"resize-none",
);
fireEvent.change(screen.getByDisplayValue("Old one-shot message"), {
target: { value: "Updated one-shot message" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=past-one-shot",
expect.any(Object),
);
});
const updateCall = vi.mocked(fetch).mock.calls.find(
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
);
expect(updateCall).toBeTruthy();
const headers = updateCall?.[1]?.headers as Record<string, string>;
expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({
name: "Past one-shot",
message: "Updated one-shot message",
});
});
it("keeps long automation details expandable without nested scrolling", async () => {
const longMessage = [
"Review the release plan and prepare a concise status update for the channel.",
"Include blockers, owners, follow-up dates, and any risky assumptions that changed since yesterday.",
"Keep the output actionable and avoid repeating context that the team already confirmed in the thread.",
"If a dependency looks stale, call it out explicitly and ask for a fresh owner update.",
"This message is intentionally long enough to require progressive disclosure in the automation details panel.",
"The full content should remain available without forcing the user into a small nested scroll area.",
].join("\n");
const history = [
{ run_at_ms: Date.UTC(2026, 3, 12, 10, 0, 0), status: "error", duration_ms: 900, error: "oldest failure" },
{ run_at_ms: Date.UTC(2026, 3, 13, 10, 0, 0), status: "error", duration_ms: 800, error: "second oldest failure" },
{ run_at_ms: Date.UTC(2026, 3, 14, 10, 0, 0), status: "ok", duration_ms: 700 },
{ run_at_ms: Date.UTC(2026, 3, 15, 10, 0, 0), status: "ok", duration_ms: 600 },
{ run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0), status: "ok", duration_ms: 500 },
{ run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0), status: "ok", duration_ms: 400 },
];
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": {
jobs: [
{
id: "long-details",
name: "Long detail automation",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: {
message: longMessage,
kind: "agent_turn",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 18, 10, 0, 0),
last_status: "ok",
pending: false,
run_history: history,
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "Release prep",
preview: "Check release blockers",
},
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" }));
const detailHeading = await screen.findByRole("heading", { name: "Long detail automation" });
const detailPanel = detailHeading.closest("article") as HTMLElement;
expect(detailPanel).not.toBeNull();
const message = Array.from(detailPanel.querySelectorAll("section div")).find(
(node) => node.textContent === longMessage,
) as HTMLElement | undefined;
expect(message).toBeTruthy();
expect(message!).toHaveClass("line-clamp-6");
fireEvent.click(within(detailPanel).getByRole("button", { name: "Show full message" }));
expect(within(detailPanel).getByRole("button", { name: "Show less" })).toBeInTheDocument();
expect(message!).not.toHaveClass("line-clamp-6");
expect(within(detailPanel).queryByText("Recent health")).not.toBeInTheDocument();
expect(within(detailPanel).queryByRole("button", { name: /Run history/ })).not.toBeInTheDocument();
expect(within(detailPanel).queryByText(/oldest failure/)).not.toBeInTheDocument();
expect(within(detailPanel).queryByText("No error recorded")).not.toBeInTheDocument();
});
it("localizes the Automations surface", async () => {
await i18n.changeLanguage("zh-CN");
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": {
jobs: [
{
id: "job-zh",
name: "每日检查",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: {
message: "检查仓库状态",
kind: "agent_turn",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0),
last_run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0),
last_status: "ok",
pending: false,
run_history: [
{
run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0),
status: "ok",
duration_ms: 500,
},
],
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "发布准备",
preview: "检查发布阻塞项",
},
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" });
fireEvent.click(within(sidebar).getByRole("button", { name: "自动任务" }));
const heading = await screen.findByRole("heading", { name: "自动任务" });
expect(heading).toBeInTheDocument();
const automationsMain = heading.closest("main");
expect(automationsMain).not.toBeNull();
expect(within(automationsMain as HTMLElement).queryByText("设置")).not.toBeInTheDocument();
expect(screen.getByText("任务队列")).toBeInTheDocument();
expect(screen.getAllByText("每日检查").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("检查仓库状态").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("每 1天")).toBeInTheDocument();
expect(screen.queryByText("最近健康状态")).not.toBeInTheDocument();
expect(screen.queryByText("近期无问题")).not.toBeInTheDocument();
expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument();
expect(document.title).toBe("自动任务 · nanobot");
});
it("fully collapses the native host sidebar and previews it on hover", async () => {
mockSessions = [
{
@@ -497,7 +845,7 @@ describe("App layout", () => {
screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" }));
fireEvent.click(screen.getByRole("button", { name: "删除" }));
await waitFor(() =>
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", {
@@ -754,15 +1102,15 @@ describe("App layout", () => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
});
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("does not show a completed dot later when the active session finishes", async () => {
it("does not show an updated dot later when the active session finishes", async () => {
mockSessions = [
{
key: "websocket:chat-a",
@@ -806,12 +1154,53 @@ describe("App layout", () => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
});
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("marks inactive sessions when a thread update arrives", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Open chat",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Scheduled update target",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Open chat$/ }));
});
act(() => {
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
});
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
});
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("restores sidebar run indicators after a page reload", async () => {
@@ -835,7 +1224,7 @@ describe("App layout", () => {
},
];
localStorage.setItem(
"nanobot-webui.sidebar.completed-runs.v1",
"nanobot-webui.sidebar.session-updates.v1",
JSON.stringify(["chat-b"]),
);
@@ -846,7 +1235,7 @@ describe("App layout", () => {
await waitFor(() =>
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
);
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
expect(attachSpy).toHaveBeenCalledWith("chat-a");
});
+43 -5
View File
@@ -18,6 +18,44 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
}
describe("ChatList", () => {
it("orders chats by latest session activity by default", () => {
const sessions = [
session({
chatId: "older",
title: "Older chat",
updatedAt: "2026-05-21T10:00:00Z",
}),
session({
chatId: "newest",
title: "Newest chat",
updatedAt: "2026-05-21T12:00:00Z",
}),
session({
chatId: "middle",
title: "Middle chat",
updatedAt: "2026-05-21T11:00:00Z",
}),
];
render(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const chatsSection = screen.getAllByRole("region")[0];
const text = chatsSection.textContent ?? "";
expect(text.indexOf("Newest chat")).toBeLessThan(text.indexOf("Middle chat"));
expect(text.indexOf("Middle chat")).toBeLessThan(text.indexOf("Older chat"));
});
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
const sessions = [
session({
@@ -179,7 +217,7 @@ describe("ChatList", () => {
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
});
it("hides the completed dot for the active chat", () => {
it("hides the updated dot for the active chat", () => {
const sessions = [
session({
chatId: "active",
@@ -200,13 +238,13 @@ describe("ChatList", () => {
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
completedChatIds={["active", "done"]}
updatedChatIds={["active", "done"]}
/>,
);
const finished = screen.getAllByLabelText("Agent finished");
expect(finished).toHaveLength(1);
expect(finished[0].firstElementChild).toHaveClass("h-2", "w-2");
const updated = screen.getAllByLabelText("New activity");
expect(updated).toHaveLength(1);
expect(updated[0].firstElementChild).toHaveClass("h-2", "w-2");
});
it("folds long default workspace chats and can show all", () => {
+10
View File
@@ -31,6 +31,7 @@ const SETTINGS_NAV_KEYS = [
"image",
"browser",
"apps",
"automations",
"runtime",
"advanced",
];
@@ -43,8 +44,17 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.nav.models",
"settings.nav.providers",
"settings.nav.apps",
"settings.nav.automations",
"settings.nav.runtime",
"settings.nav.advanced",
"sidebar.automations",
"settings.automations.filters.active",
"settings.automations.queue",
"settings.automations.empty",
"settings.automations.systemTask",
"settings.automations.labels.schedule",
"settings.automations.status.active",
"settings.automations.deleteTitle",
"settings.sections.interface",
"settings.sections.localPreferences",
"settings.sections.webSearch",
+22 -1
View File
@@ -159,8 +159,9 @@ const installedAnyGen = {
function renderSettingsView(
options: {
initialSection?: "overview" | "apps" | "advanced" | "models";
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models";
initialSettings?: SettingsPayload;
showSidebar?: boolean;
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
@@ -171,6 +172,7 @@ function renderSettingsView(
theme="light"
initialSection={options.initialSection ?? "apps"}
initialSettings={options.initialSettings}
showSidebar={options.showSidebar}
onToggleTheme={() => {}}
onBackToChat={() => {}}
onModelNameChange={() => {}}
@@ -187,6 +189,25 @@ describe("SettingsView Apps catalog", () => {
vi.unstubAllGlobals();
});
it("does not show the Settings kicker on the standalone Automations surface", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") return jsonResponse({ jobs: [] });
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
});
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);