Merge PR #4330: feat(webui): add automation management view
feat(webui): add automation management view
This commit is contained in:
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 l’automatisation",
|
||||
"save": "Enregistrer",
|
||||
"deleteTitle": "Supprimer l’automatisation",
|
||||
"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 d’exé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 d’exé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": "L’intervalle doit être un nombre positif.",
|
||||
"cronRequired": "L’expression cron est obligatoire.",
|
||||
"timeRequired": "L’heure d’exécution est obligatoire.",
|
||||
"futureRequired": "L’heure d’exé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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} ごと",
|
||||
|
||||
@@ -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}}마다",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user