From 747f0a08c7e435e082b32e655a2cb6bd56779387 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Sun, 14 Jun 2026 18:24:59 +0800
Subject: [PATCH] fix(webui): improve automation management
---
nanobot/webui/ws_http.py | 86 ++-
tests/channels/test_websocket_http_routes.py | 74 +-
.../src/components/settings/SettingsView.tsx | 685 ++++++++++++++++--
webui/src/i18n/locales/en/common.json | 63 +-
webui/src/i18n/locales/es/common.json | 63 +-
webui/src/i18n/locales/fr/common.json | 63 +-
webui/src/i18n/locales/id/common.json | 63 +-
webui/src/i18n/locales/ja/common.json | 63 +-
webui/src/i18n/locales/ko/common.json | 63 +-
webui/src/i18n/locales/vi/common.json | 63 +-
webui/src/i18n/locales/zh-CN/common.json | 63 +-
webui/src/i18n/locales/zh-TW/common.json | 63 +-
webui/src/lib/api.ts | 21 +
webui/src/lib/types.ts | 11 +
webui/src/tests/api.test.ts | 23 +
15 files changed, 1376 insertions(+), 91 deletions(-)
diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py
index 787c141d..e8b4445e 100644
--- a/nanobot/webui/ws_http.py
+++ b/nanobot/webui/ws_http.py
@@ -23,6 +23,7 @@ from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
+from nanobot.cron.types import 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
@@ -80,6 +81,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
@@ -529,7 +531,7 @@ class GatewayHTTPHandler:
) -> Response | None:
if got == "/api/webui/automations":
return self._handle_webui_automations(request)
- m = re.match(r"^/api/webui/automations/(enable|disable|delete|run)$", got)
+ 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
@@ -594,6 +596,21 @@ class GatewayHTTPHandler:
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)
+ 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")
@@ -757,5 +774,72 @@ 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:
+ return None
+ return values if isinstance(values, dict) else None
+
+
+def _parse_automation_update(values: dict[str, Any]) -> dict[str, Any] | str:
+ update: dict[str, Any] = {}
+ if "name" in values:
+ name = str(values.get("name") or "").strip()
+ if not name:
+ return "name cannot be empty"
+ update["name"] = name
+ if "message" in values:
+ message = str(values.get("message") or "").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
+ update["schedule"] = parsed_schedule
+ update["delete_after_run"] = parsed_schedule.kind == "at"
+ return update
+
+
+def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
+ kind = str(values.get("kind") or "").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":
+ expr = str(values.get("expr") or "").strip()
+ if not expr:
+ return "cron schedule requires expr"
+ tz = str(values.get("tz") or "").strip() or None
+ return CronSchedule(kind="cron", expr=expr, tz=tz)
+ 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 _positive_int(value: Any) -> int | None:
+ if isinstance(value, bool):
+ return None
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ return None
+ return parsed if parsed > 0 else None
+
+
def _is_websocket_channel_session_key(key: str) -> bool:
return key.startswith("websocket:")
diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py
index 8cbbe80c..06912f69 100644
--- a/tests/channels/test_websocket_http_routes.py
+++ b/tests/channels/test_websocket_http_routes.py
@@ -3,6 +3,8 @@
import asyncio
import functools
import json
+import random
+import socket
import threading
import time
from pathlib import Path
@@ -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,
@@ -817,6 +831,8 @@ async def test_session_delete_removes_file(
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",
@@ -857,19 +873,19 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
session_manager=session_manager,
cron_service=cron,
cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(),
- port=29932,
+ port=port,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
- deny = await _http_get("http://127.0.0.1:29932/api/webui/automations")
- assert deny.status_code == 401
+ deny = await _http_get(f"{base_url}/api/webui/automations")
+ assert deny.status_code == 401, deny.text
- boot = await _http_get("http://127.0.0.1:29932/webui/bootstrap")
+ boot = await _http_get(f"{base_url}/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
- "http://127.0.0.1:29932/api/webui/automations",
+ f"{base_url}/api/webui/automations",
headers=auth,
)
assert resp.status_code == 200
@@ -886,8 +902,42 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
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"
+
+ 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"http://127.0.0.1:29932/api/webui/automations/disable?id={user_job.id}",
+ f"{base_url}/api/webui/automations/disable?id={user_job.id}",
headers=auth,
)
assert disabled.status_code == 200
@@ -895,29 +945,29 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
assert by_id[user_job.id]["enabled"] is False
disabled_run = await _http_get(
- f"http://127.0.0.1:29932/api/webui/automations/run?id={user_job.id}",
+ f"{base_url}/api/webui/automations/run?id={user_job.id}",
headers=auth,
)
assert disabled_run.status_code == 409
protected_delete = await _http_get(
- "http://127.0.0.1:29932/api/webui/automations/delete?id=heartbeat",
+ f"{base_url}/api/webui/automations/delete?id=heartbeat",
headers=auth,
)
assert protected_delete.status_code == 403
protected_disable = await _http_get(
- "http://127.0.0.1:29932/api/webui/automations/disable?id=heartbeat",
+ f"{base_url}/api/webui/automations/disable?id=heartbeat",
headers=auth,
)
assert protected_disable.status_code == 403
protected_run = await _http_get(
- "http://127.0.0.1:29932/api/webui/automations/run?id=heartbeat",
+ f"{base_url}/api/webui/automations/run?id=heartbeat",
headers=auth,
)
assert protected_run.status_code == 403
enabled = await _http_get(
- f"http://127.0.0.1:29932/api/webui/automations/enable?id={user_job.id}",
+ f"{base_url}/api/webui/automations/enable?id={user_job.id}",
headers=auth,
)
assert enabled.status_code == 200
@@ -925,7 +975,7 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
assert by_id[user_job.id]["enabled"] is True
deleted = await _http_get(
- f"http://127.0.0.1:29932/api/webui/automations/delete?id={user_job.id}",
+ f"{base_url}/api/webui/automations/delete?id={user_job.id}",
headers=auth,
)
assert deleted.status_code == 200
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
index 58d85737..2cf03b23 100644
--- a/webui/src/components/settings/SettingsView.tsx
+++ b/webui/src/components/settings/SettingsView.tsx
@@ -5,12 +5,14 @@ import {
useMemo,
useState,
type Dispatch,
+ type FormEvent,
type ReactNode,
type SetStateAction,
} from "react";
import {
Activity,
ArrowUpCircle,
+ ArrowUpDown,
Bot,
Brain,
Check,
@@ -93,6 +95,7 @@ import {
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
+ updateAutomation,
updateImageGenerationSettings,
updateMcpServerTools,
updateModelConfiguration,
@@ -116,6 +119,7 @@ import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider";
import type {
AutomationsPayload,
+ AutomationUpdatePayload,
CliAppInfo,
CliAppsPayload,
ImageGenerationSettingsUpdate,
@@ -148,6 +152,7 @@ type LocalDensity = "comfortable" | "compact";
type LocalActivityMode = "auto" | "expanded";
type AppsKindFilter = "all" | "cli" | "mcp";
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
+type AutomationSort = "next" | "last" | "updated" | "name";
type AutomationAction = "enable" | "disable" | "delete" | "run";
type AppsCatalogItem =
| { id: string; kind: "cli"; app: CliAppInfo }
@@ -546,6 +551,7 @@ export function SettingsView({
const [appsQuery, setAppsQuery] = useState("");
const [automationsQuery, setAutomationsQuery] = useState("");
const [automationsFilter, setAutomationsFilter] = useState("all");
+ const [automationsSort, setAutomationsSort] = useState("next");
const [cliAppsMessage, setCliAppsMessage] = useState(null);
const [cliAppsError, setCliAppsError] = useState(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState(null);
@@ -556,6 +562,8 @@ export function SettingsView({
const [automationAction, setAutomationAction] = useState(null);
const [automationPendingDelete, setAutomationPendingDelete] =
useState(null);
+ const [automationPendingEdit, setAutomationPendingEdit] =
+ useState(null);
const [mcpFieldValues, setMcpFieldValues] = useState>>({});
const [customMcpForm, setCustomMcpForm] = useState(DEFAULT_CUSTOM_MCP_FORM);
const [mcpConfigImport, setMcpConfigImport] = useState("");
@@ -718,25 +726,51 @@ export function SettingsView({
};
}, [activeSection, token]);
+ const refreshAutomations = useCallback(
+ async (showLoading = false) => {
+ if (showLoading) setAutomationsLoading(true);
+ try {
+ const payload = await fetchAutomations(token);
+ setAutomations(payload);
+ setAutomationsError(null);
+ } catch (err) {
+ setAutomationsError((err as Error).message);
+ } finally {
+ if (showLoading) setAutomationsLoading(false);
+ }
+ },
+ [token],
+ );
+
useEffect(() => {
if (activeSection !== "automations") return;
let cancelled = false;
- setAutomationsLoading(true);
- fetchAutomations(token)
- .then((payload) => {
- if (!cancelled) {
- setAutomations(payload);
- setAutomationsError(null);
- }
- })
- .catch((err) => {
+ const refresh = async (showLoading = false) => {
+ if (cancelled) return;
+ if (showLoading) setAutomationsLoading(true);
+ try {
+ const payload = await fetchAutomations(token);
+ if (cancelled) return;
+ setAutomations(payload);
+ setAutomationsError(null);
+ } catch (err) {
if (!cancelled) setAutomationsError((err as Error).message);
- })
- .finally(() => {
- if (!cancelled) setAutomationsLoading(false);
- });
+ } finally {
+ if (!cancelled && showLoading) setAutomationsLoading(false);
+ }
+ };
+ void refresh(true);
+ const interval = window.setInterval(() => void refresh(false), 5000);
+ const refreshOnFocus = () => {
+ if (document.visibilityState !== "hidden") void refresh(false);
+ };
+ window.addEventListener("focus", refreshOnFocus);
+ document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
+ window.clearInterval(interval);
+ window.removeEventListener("focus", refreshOnFocus);
+ document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [activeSection, token]);
@@ -1275,6 +1309,28 @@ export function SettingsView({
const payload = await runAutomationAction(token, action, job.id);
setAutomations(payload);
if (action === "delete") setAutomationPendingDelete(null);
+ if (action === "run") {
+ window.setTimeout(() => void refreshAutomations(false), 1200);
+ window.setTimeout(() => void refreshAutomations(false), 4000);
+ }
+ } catch (err) {
+ setAutomationsError((err as Error).message);
+ } finally {
+ setAutomationAction(null);
+ }
+ };
+
+ const handleAutomationEdit = async (
+ job: SessionAutomationJob,
+ values: AutomationUpdatePayload,
+ ) => {
+ const key = `update:${job.id}`;
+ setAutomationAction(key);
+ setAutomationsError(null);
+ try {
+ const payload = await updateAutomation(token, job.id, values);
+ setAutomations(payload);
+ setAutomationPendingEdit(null);
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
@@ -1569,11 +1625,14 @@ export function SettingsView({
loading={automationsLoading}
query={automationsQuery}
filter={automationsFilter}
+ sort={automationsSort}
actionKey={automationAction}
error={automationsError}
onQueryChange={setAutomationsQuery}
onFilterChange={setAutomationsFilter}
+ onSortChange={setAutomationsSort}
onAction={handleAutomationAction}
+ onRequestEdit={setAutomationPendingEdit}
onRequestDelete={setAutomationPendingDelete}
/>
);
@@ -1644,6 +1703,15 @@ export function SettingsView({
onConfirm={(job) => handleAutomationAction("delete", job)}
/>
+ {
+ if (!open) setAutomationPendingEdit(null);
+ }}
+ onSave={handleAutomationEdit}
+ />
+
void;
onFilterChange: (value: AutomationFilter) => void;
+ onSortChange: (value: AutomationSort) => void;
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise
;
+ onRequestEdit: (job: SessionAutomationJob) => void;
onRequestDelete: (job: SessionAutomationJob) => void;
}) {
const { t, i18n } = useTranslation();
@@ -3356,20 +3430,29 @@ function AutomationsSettings({
t(key, { defaultValue: fallback, ...(values ?? {}) });
const jobs = payload?.jobs ?? [];
const normalizedQuery = query.trim().toLowerCase();
- const filtered = jobs
+ const filtered = sortAutomationJobs(jobs, sort)
.filter((job) => automationMatchesFilter(job, filter))
.filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery));
- const activeCount = jobs.filter((job) => job.enabled && !job.protected).length;
- const pausedCount = jobs.filter((job) => !job.enabled && !job.protected).length;
- const failedCount = jobs.filter((job) => job.state.last_status === "error").length;
+ const activeCount = jobs.filter((job) => {
+ const key = automationStatusKey(job);
+ return key === "active" || key === "running";
+ }).length;
+ const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length;
+ const failedCount = jobs.filter(automationNeedsAttention).length;
const systemCount = jobs.filter((job) => job.protected).length;
const filterOptions = [
{ value: "all", label: tx("settings.automations.filters.all", "All") },
{ value: "active", label: tx("settings.automations.filters.active", "Active") },
{ value: "paused", label: tx("settings.automations.filters.paused", "Paused") },
- { value: "failed", label: tx("settings.automations.filters.failed", "Failed") },
+ { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention") },
{ value: "system", label: tx("settings.automations.filters.system", "System") },
];
+ const sortLabel = {
+ next: tx("settings.automations.sort.next", "Next run"),
+ last: tx("settings.automations.sort.last", "Last run"),
+ updated: tx("settings.automations.sort.updated", "Updated"),
+ name: tx("settings.automations.sort.name", "Name"),
+ } satisfies Record;
return (
@@ -3377,7 +3460,7 @@ function AutomationsSettings({
@@ -3391,11 +3474,33 @@ function AutomationsSettings({
className="h-9 rounded-full bg-background/85 pl-9 text-[13px]"
/>
- onFilterChange(value as AutomationFilter)}
- />
+
+
+
+
+
+ {sortLabel[sort]}
+
+
+
+
+ {(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
+ onSortChange(value)}>
+ {sortLabel[value]}
+ {sort === value ? : null}
+
+ ))}
+
+
+
onFilterChange(value as AutomationFilter)}
+ />
+
@@ -3422,15 +3527,26 @@ function AutomationsSettings({
locale={i18n.resolvedLanguage || i18n.language}
actionKey={actionKey}
onAction={onAction}
+ onRequestEdit={onRequestEdit}
onRequestDelete={onRequestDelete}
/>
))}
) : (
- {jobs.length
- ? tx("settings.automations.noMatches", "No automations match this view.")
- : tx("settings.automations.empty", "No automations yet.")}
+
+ {jobs.length
+ ? tx("settings.automations.noMatches", "No automations match this view.")
+ : tx("settings.automations.empty", "No automations yet.")}
+
+ {!jobs.length ? (
+
+ {tx(
+ "settings.automations.emptyHint",
+ "Create one from the chat or channel where it should run so nanobot keeps the right context.",
+ )}
+
+ ) : null}
)}
@@ -3452,12 +3568,14 @@ function AutomationRow({
locale,
actionKey,
onAction,
+ onRequestEdit,
onRequestDelete,
}: {
job: SessionAutomationJob;
locale: string;
actionKey: string | null;
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise;
+ onRequestEdit: (job: SessionAutomationJob) => void;
onRequestDelete: (job: SessionAutomationJob) => void;
}) {
const { t } = useTranslation();
@@ -3473,6 +3591,7 @@ function AutomationRow({
const canRun = canManage && job.enabled && !job.state.pending;
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
+ const needsRecreation = automationNeedsRecreation(job);
return (
@@ -3492,16 +3611,25 @@ function AutomationRow({
-
+
{formatAutomationSchedule(job, locale, tx)}
-
+
{formatAutomationNext(job, tx)}
-
+
{formatAutomationLast(job, locale, tx)}
-
+
{originHref ? (
+ {needsRecreation ? (
+
+ {tx(
+ "settings.automations.legacyWarning",
+ "This older automation is missing its target chat. Recreate it from the chat or channel where it should run.",
+ )}
+
+ ) : null}
+
{job.state.last_error ? (
{job.state.last_error}
@@ -3531,7 +3668,12 @@ function AutomationRow({
{history.slice(-4).map((record) => {
const statusLabel = automationRunStatusLabel(record.status, tx);
const duration = formatAutomationRunDuration(record.duration_ms, locale, tx);
- const visibleLabel = record.status === "ok" ? duration : `${statusLabel} · ${duration}`;
+ const visibleLabel = record.status === "ok"
+ ? duration
+ : tx("settings.automations.history.statusWithDuration", "{{status}} · {{duration}}", {
+ status: statusLabel,
+ duration,
+ });
const detail = record.error || fmtDateTime(record.run_at_ms, locale);
const accessibleLabel = `${statusLabel} · ${duration} · ${detail}`;
return (
@@ -3555,11 +3697,35 @@ function AutomationRow({
) : null}
+
+
+ {job.created_at_ms ? (
+
+ {tx("settings.automations.meta.created", "Created {{time}}", {
+ time: fmtDateTime(job.created_at_ms, locale),
+ })}
+
+ ) : null}
+ {job.updated_at_ms ? (
+
+ {tx("settings.automations.meta.updated", "Updated {{time}}", {
+ time: fmtDateTime(job.updated_at_ms, locale),
+ })}
+
+ ) : null}
+
{canManage ? (
<>
+
onRequestEdit(job)}
+ >
+
+
{label}
- {children}
+
+ {children}
+
);
}
+type AutomationEveryUnit = "second" | "minute" | "hour" | "day";
+
+type AutomationEditDraft = {
+ name: string;
+ message: string;
+ scheduleKind: "at" | "every" | "cron";
+ everyValue: string;
+ everyUnit: AutomationEveryUnit;
+ cronExpr: string;
+ tz: string;
+ atLocal: string;
+};
+
+const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [
+ { value: "second", ms: 1000 },
+ { value: "minute", ms: 60_000 },
+ { value: "hour", ms: 3_600_000 },
+ { value: "day", ms: 86_400_000 },
+];
+
+function AutomationEditDialog({
+ job,
+ saving,
+ onOpenChange,
+ onSave,
+}: {
+ job: SessionAutomationJob | null;
+ saving: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (job: SessionAutomationJob, values: AutomationUpdatePayload) => void | Promise;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ const [draft, setDraft] = useState(() => automationDraftFromJob(null));
+
+ useEffect(() => {
+ setDraft(automationDraftFromJob(job));
+ }, [job]);
+
+ const validation = automationEditDraftError(draft, tx);
+ const scheduleOptions = [
+ { value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") },
+ { value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") },
+ { value: "at", label: tx("settings.automations.scheduleTypes.at", "Once") },
+ ];
+ const unitLabels: Record = {
+ second: tx("settings.automations.everyUnits.second", "Seconds"),
+ minute: tx("settings.automations.everyUnits.minute", "Minutes"),
+ hour: tx("settings.automations.everyUnits.hour", "Hours"),
+ day: tx("settings.automations.everyUnits.day", "Days"),
+ };
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ const payload = automationUpdatePayloadFromDraft(draft);
+ if (!job || typeof payload === "string") return;
+ void onSave(job, payload);
+ };
+
+ return (
+
+ {job ? (
+
+
+
+ ) : null}
+
+ );
+}
+
function AutomationDeleteDialog({
job,
deleting,
@@ -3667,6 +4068,130 @@ function AutomationDeleteDialog({
);
}
+function automationNeedsRecreation(job: SessionAutomationJob): boolean {
+ return !job.protected && !job.origin && job.payload.kind === "agent_turn";
+}
+
+function automationNeedsAttention(job: SessionAutomationJob): boolean {
+ return automationNeedsRecreation(job) || job.state.last_status === "error";
+}
+
+function automationStatusKey(
+ job: SessionAutomationJob,
+): "active" | "running" | "paused" | "failed" | "system" | "needs_setup" | "completed" | "idle" {
+ if (job.protected) return "system";
+ if (automationNeedsRecreation(job)) return "needs_setup";
+ if (job.state.pending) return "running";
+ if (!job.enabled) return "paused";
+ if (job.state.last_status === "error") return "failed";
+ if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
+ return "completed";
+ }
+ if (!job.state.next_run_at_ms) return "idle";
+ return "active";
+}
+
+function sortAutomationJobs(jobs: SessionAutomationJob[], sort: AutomationSort): SessionAutomationJob[] {
+ const byName = (left: SessionAutomationJob, right: SessionAutomationJob) =>
+ (left.name || left.id).localeCompare(right.name || right.id);
+ return [...jobs].sort((left, right) => {
+ if (sort === "name") return byName(left, right);
+ if (sort === "last") {
+ return (right.state.last_run_at_ms ?? 0) - (left.state.last_run_at_ms ?? 0) || byName(left, right);
+ }
+ if (sort === "updated") {
+ return (right.updated_at_ms ?? 0) - (left.updated_at_ms ?? 0) || byName(left, right);
+ }
+ const leftNext = left.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
+ const rightNext = right.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
+ return leftNext - rightNext || byName(left, right);
+ });
+}
+
+function automationDraftFromJob(job: SessionAutomationJob | null): AutomationEditDraft {
+ const every = automationIntervalDraft(job?.schedule.every_ms ?? 3_600_000);
+ const scheduleKind = job?.schedule.kind === "at" || job?.schedule.kind === "cron"
+ ? job.schedule.kind
+ : "every";
+ return {
+ name: job?.name ?? "",
+ message: job?.payload.message ?? "",
+ scheduleKind,
+ everyValue: every.value,
+ everyUnit: every.unit,
+ cronExpr: job?.schedule.expr ?? "0 9 * * *",
+ tz: job?.schedule.tz ?? "",
+ atLocal: formatLocalDateTimeInput(job?.schedule.at_ms ?? Date.now() + 3_600_000),
+ };
+}
+
+function automationIntervalDraft(ms: number): { value: string; unit: AutomationEveryUnit } {
+ for (const unit of [...AUTOMATION_EVERY_UNITS].reverse()) {
+ if (ms >= unit.ms && ms % unit.ms === 0) {
+ return { value: String(ms / unit.ms), unit: unit.value };
+ }
+ }
+ return { value: String(Math.max(1, Math.round(ms / 60_000))), unit: "minute" };
+}
+
+function formatLocalDateTimeInput(ms: number): string {
+ const date = new Date(ms);
+ if (!Number.isFinite(date.getTime())) return "";
+ const local = new Date(ms - date.getTimezoneOffset() * 60_000);
+ return local.toISOString().slice(0, 16);
+}
+
+function automationEditDraftError(
+ draft: AutomationEditDraft,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string | null {
+ if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
+ if (!draft.message.trim()) {
+ return tx("settings.automations.validation.messageRequired", "Message is required.");
+ }
+ if (draft.scheduleKind === "every") {
+ const value = Number(draft.everyValue);
+ if (!Number.isInteger(value) || value <= 0) {
+ return tx("settings.automations.validation.intervalRequired", "Interval must be a positive number.");
+ }
+ }
+ if (draft.scheduleKind === "cron" && !draft.cronExpr.trim()) {
+ return tx("settings.automations.validation.cronRequired", "Cron expression is required.");
+ }
+ if (draft.scheduleKind === "at") {
+ const atMs = new Date(draft.atLocal).getTime();
+ if (!Number.isFinite(atMs)) {
+ return tx("settings.automations.validation.timeRequired", "Run time is required.");
+ }
+ if (atMs <= Date.now()) {
+ return tx("settings.automations.validation.futureRequired", "Run time must be in the future.");
+ }
+ }
+ return null;
+}
+
+function automationUpdatePayloadFromDraft(draft: AutomationEditDraft): AutomationUpdatePayload | string {
+ const name = draft.name.trim();
+ const message = draft.message.trim();
+ if (!name || !message) return "invalid";
+ const payload: AutomationUpdatePayload = { name, message };
+ if (draft.scheduleKind === "every") {
+ const unit = AUTOMATION_EVERY_UNITS.find((candidate) => candidate.value === draft.everyUnit);
+ const value = Number(draft.everyValue);
+ if (!unit || !Number.isInteger(value) || value <= 0) return "invalid";
+ payload.schedule = { kind: "every", every_ms: value * unit.ms };
+ } else if (draft.scheduleKind === "cron") {
+ const expr = draft.cronExpr.trim();
+ if (!expr) return "invalid";
+ payload.schedule = { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
+ } else {
+ const atMs = new Date(draft.atLocal).getTime();
+ if (!Number.isFinite(atMs)) return "invalid";
+ payload.schedule = { kind: "at", at_ms: atMs };
+ }
+ return payload;
+}
+
function automationSearchText(job: SessionAutomationJob): string {
const originText = job.origin
? job.origin.channel === "websocket"
@@ -3688,9 +4213,10 @@ function automationSearchText(job: SessionAutomationJob): string {
}
function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
- if (filter === "active") return job.enabled && !job.protected;
- if (filter === "paused") return !job.enabled && !job.protected;
- if (filter === "failed") return job.state.last_status === "error";
+ const status = automationStatusKey(job);
+ if (filter === "active") return status === "active" || status === "running";
+ if (filter === "paused") return status === "paused";
+ if (filter === "failed") return automationNeedsAttention(job);
if (filter === "system") return Boolean(job.protected);
return true;
}
@@ -3699,12 +4225,24 @@ function automationStatus(
job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record) => string,
): { label: string; tone: "neutral" | "success" | "warning" } {
- if (job.protected) return { label: tx("settings.automations.status.system", "System"), tone: "neutral" };
- if (job.state.pending) return { label: tx("settings.automations.status.pending", "Pending"), tone: "warning" };
- if (!job.enabled) return { label: tx("settings.automations.status.paused", "Paused"), tone: "neutral" };
- if (job.state.last_status === "error") {
+ const status = automationStatusKey(job);
+ if (status === "system") return { label: tx("settings.automations.status.system", "System"), tone: "neutral" };
+ if (status === "needs_setup") {
+ return { label: tx("settings.automations.status.needsSetup", "Needs setup"), tone: "warning" };
+ }
+ if (status === "running") {
+ return { label: tx("settings.automations.status.running", "Running now"), tone: "warning" };
+ }
+ if (status === "paused") return { label: tx("settings.automations.status.paused", "Paused"), tone: "neutral" };
+ if (status === "failed") {
return { label: tx("settings.automations.status.failed", "Failed"), tone: "warning" };
}
+ if (status === "completed") {
+ return { label: tx("settings.automations.status.completed", "Completed"), tone: "neutral" };
+ }
+ if (status === "idle") {
+ return { label: tx("settings.automations.status.noSchedule", "No schedule"), tone: "neutral" };
+ }
return { label: tx("settings.automations.status.active", "Active"), tone: "success" };
}
@@ -3765,6 +4303,15 @@ function formatAutomationSchedule(
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
+ const summary = formatCronScheduleSummary(job.schedule.expr, tx);
+ if (summary) {
+ return job.schedule.tz
+ ? tx("settings.automations.schedule.withTz", "{{summary}} · {{tz}}", {
+ summary,
+ tz: job.schedule.tz,
+ })
+ : summary;
+ }
return job.schedule.tz
? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", {
expr: job.schedule.expr,
@@ -3775,16 +4322,70 @@ function formatAutomationSchedule(
return tx("settings.automations.schedule.custom", "Custom schedule");
}
+function formatCronScheduleSummary(
+ expr: string,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string | null {
+ const parts = expr.trim().split(/\s+/);
+ if (parts.length !== 5) return null;
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
+ const numericMinute = cronNumericToken(minute, 59);
+ const numericHour = cronNumericToken(hour, 23);
+ const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
+ const workdays = dayOfMonth === "*" && month === "*" && ["1-5", "MON-FRI", "mon-fri"].includes(dayOfWeek);
+
+ if (numericMinute !== null && numericHour !== null) {
+ const time = `${String(numericHour).padStart(2, "0")}:${String(numericMinute).padStart(2, "0")}`;
+ if (everyDay) return tx("settings.automations.schedule.dailyAt", "Daily at {{time}}", { time });
+ if (workdays) return tx("settings.automations.schedule.weekdaysAt", "Weekdays at {{time}}", { time });
+ }
+
+ if (everyDay && numericMinute !== null && hour === "*") {
+ return tx("settings.automations.schedule.hourlyAt", "Hourly at :{{minute}}", {
+ minute: String(numericMinute).padStart(2, "0"),
+ });
+ }
+
+ const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
+ if (everyDay && numericMinute !== null && range) {
+ const start = Number(range[1]);
+ const end = Number(range[2]);
+ if (start > 23 || end > 23) return null;
+ return tx("settings.automations.schedule.hourlyWindow", "Hourly {{start}}-{{end}} at :{{minute}}", {
+ start: String(start).padStart(2, "0"),
+ end: String(end).padStart(2, "0"),
+ minute: String(numericMinute).padStart(2, "0"),
+ });
+ }
+
+ return null;
+}
+
+function cronNumericToken(value: string, max: number): number | null {
+ if (!/^\d{1,2}$/.test(value)) return null;
+ const parsed = Number(value);
+ return parsed <= max ? parsed : null;
+}
+
function formatAutomationNext(
job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record) => string,
): string {
if (!job.enabled) return tx("settings.automations.next.paused", "Paused");
- if (job.state.pending) return tx("settings.automations.next.pending", "Running soon");
+ if (job.state.pending) return tx("settings.automations.next.pending", "Running now");
if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
return relativeTime(job.state.next_run_at_ms);
}
+function formatAutomationNextTitle(
+ job: SessionAutomationJob,
+ locale: string,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string {
+ if (!job.state.next_run_at_ms) return formatAutomationNext(job, tx);
+ return fmtDateTime(job.state.next_run_at_ms, locale);
+}
+
function formatAutomationLast(
job: SessionAutomationJob,
locale: string,
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 10295173..dad2ca2f 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "Active",
"paused": "Paused",
- "failed": "Failed",
+ "failed": "Needs attention",
"system": "System"
},
"filters": {
"all": "All",
"active": "Active",
"paused": "Paused",
- "failed": "Failed",
+ "failed": "Needs attention",
"system": "System"
},
+ "sort": {
+ "next": "Next run",
+ "last": "Last run",
+ "updated": "Updated",
+ "name": "Name"
+ },
"search": "Search automation, message, session, or cron expression",
"queue": "Queue",
"loading": "Loading automations...",
"noMatches": "No automations match this view.",
"empty": "No automations yet.",
+ "emptyHint": "Create one from the chat or channel where it should run so nanobot keeps the right context.",
"oneShot": "One-time",
"systemTask": "System-managed automation",
"labels": {
@@ -495,18 +502,27 @@
"runNow": "Run now",
"pause": "Pause",
"resume": "Resume",
+ "edit": "Edit",
"delete": "Delete",
"protected": "Protected",
+ "editTitle": "Edit automation",
+ "editDescription": "Update the prompt and schedule. The linked chat stays unchanged.",
+ "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",
"pending": "Pending",
+ "running": "Running now",
+ "needsSetup": "Needs setup",
"paused": "Paused",
"failed": "Failed",
+ "completed": "Completed",
+ "noSchedule": "No schedule",
"active": "Active"
},
+ "legacyWarning": "This older automation is missing its target chat. Recreate it from the chat or channel where it should run.",
"origin": {
"system": "System",
"unknown": "No linked chat",
@@ -534,11 +550,16 @@
"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 soon",
+ "pending": "Running now",
"none": "No next run"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "Completed",
"error": "Error",
"skipped": "Skipped",
- "recent": "Recent runs"
+ "recent": "Recent runs",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "Created {{time}}",
+ "updated": "Updated {{time}}"
+ },
+ "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."
},
"duration": {
"lessThanSecond": "< 1 second"
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index d1f793de..d2b715ae 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "Activas",
"paused": "Pausadas",
- "failed": "Fallidas",
+ "failed": "Requieren atención",
"system": "Sistema"
},
"filters": {
"all": "Todas",
"active": "Activas",
"paused": "Pausadas",
- "failed": "Fallidas",
+ "failed": "Requieren atención",
"system": "Sistema"
},
+ "sort": {
+ "next": "Próxima ejecución",
+ "last": "Última ejecución",
+ "updated": "Actualizada",
+ "name": "Nombre"
+ },
"search": "Buscar tarea, mensaje, sesión o expresión cron",
"queue": "Cola",
"loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
"empty": "Aún no hay automatizaciones.",
+ "emptyHint": "Crea una desde el chat o canal donde debe ejecutarse para que nanobot conserve el contexto correcto.",
"oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema",
"labels": {
@@ -495,18 +502,27 @@
"runNow": "Ejecutar ahora",
"pause": "Pausar",
"resume": "Reanudar",
+ "edit": "Editar",
"delete": "Eliminar",
"protected": "Protegida",
+ "editTitle": "Editar automatización",
+ "editDescription": "Actualiza el prompt y la programación. El chat vinculado no cambia.",
+ "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",
"pending": "Pendiente",
+ "running": "Ejecutándose ahora",
+ "needsSetup": "Requiere configuración",
"paused": "Pausada",
"failed": "Fallida",
+ "completed": "Completada",
+ "noSchedule": "Sin programación",
"active": "Activa"
},
+ "legacyWarning": "Esta automatización antigua no tiene chat de destino. Vuelve a crearla desde el chat o canal donde debe ejecutarse.",
"origin": {
"system": "Sistema",
"unknown": "Sin chat vinculado",
@@ -534,11 +550,16 @@
"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": "Se ejecutará pronto",
+ "pending": "Ejecutándose ahora",
"none": "Sin próxima ejecución"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "Completada",
"error": "Error",
"skipped": "Omitida",
- "recent": "Ejecuciones recientes"
+ "recent": "Ejecuciones recientes",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "Creada {{time}}",
+ "updated": "Actualizada {{time}}"
+ },
+ "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."
},
"duration": {
"lessThanSecond": "menos de 1 segundo"
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index 992be5ed..f321f48c 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "Actives",
"paused": "En pause",
- "failed": "Échouées",
+ "failed": "À traiter",
"system": "Système"
},
"filters": {
"all": "Toutes",
"active": "Actives",
"paused": "En pause",
- "failed": "Échouées",
+ "failed": "À traiter",
"system": "Système"
},
+ "sort": {
+ "next": "Prochaine exécution",
+ "last": "Dernière exécution",
+ "updated": "Mise à jour",
+ "name": "Nom"
+ },
"search": "Rechercher une tâche, un message, une session ou une expression cron",
"queue": "File",
"loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.",
"empty": "Aucune automatisation pour le moment.",
+ "emptyHint": "Créez-en une depuis la discussion ou le canal où elle doit s’exécuter afin que nanobot conserve le bon contexte.",
"oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système",
"labels": {
@@ -495,18 +502,27 @@
"runNow": "Exécuter maintenant",
"pause": "Mettre en pause",
"resume": "Reprendre",
+ "edit": "Modifier",
"delete": "Supprimer",
"protected": "Protégée",
+ "editTitle": "Modifier l’automatisation",
+ "editDescription": "Modifiez le prompt et le planning. La discussion liée ne change pas.",
+ "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",
"pending": "En attente",
+ "running": "En cours d’exécution",
+ "needsSetup": "Configuration requise",
"paused": "En pause",
"failed": "Échouée",
+ "completed": "Terminée",
+ "noSchedule": "Aucun planning",
"active": "En cours"
},
+ "legacyWarning": "Cette ancienne automatisation n’a pas de discussion cible. Recréez-la depuis la discussion ou le canal où elle doit s’exécuter.",
"origin": {
"system": "Système",
"unknown": "Aucune discussion liée",
@@ -534,11 +550,16 @@
"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": "Exécution prochaine",
+ "pending": "En cours d’exécution",
"none": "Aucune prochaine exécution"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "Terminée",
"error": "Erreur",
"skipped": "Ignorée",
- "recent": "Exécutions récentes"
+ "recent": "Exécutions récentes",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "Créée {{time}}",
+ "updated": "Mise à jour {{time}}"
+ },
+ "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."
},
"duration": {
"lessThanSecond": "moins de 1 seconde"
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 01ed7a93..7915a01f 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "Aktif",
"paused": "Dijeda",
- "failed": "Gagal",
+ "failed": "Perlu ditangani",
"system": "Sistem"
},
"filters": {
"all": "Semua",
"active": "Aktif",
"paused": "Dijeda",
- "failed": "Gagal",
+ "failed": "Perlu ditangani",
"system": "Sistem"
},
+ "sort": {
+ "next": "Jalankan berikutnya",
+ "last": "Jalankan terakhir",
+ "updated": "Diperbarui",
+ "name": "Nama"
+ },
"search": "Cari tugas, pesan, sesi, atau ekspresi cron",
"queue": "Antrean",
"loading": "Memuat otomasi...",
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
"empty": "Belum ada otomasi.",
+ "emptyHint": "Buat dari chat atau channel tempat otomasi akan berjalan agar nanobot menyimpan konteks yang benar.",
"oneShot": "Satu kali",
"systemTask": "Automasi yang dikelola sistem",
"labels": {
@@ -495,18 +502,27 @@
"runNow": "Jalankan sekarang",
"pause": "Jeda",
"resume": "Lanjutkan",
+ "edit": "Edit",
"delete": "Hapus",
"protected": "Terlindungi",
+ "editTitle": "Edit otomasi",
+ "editDescription": "Perbarui prompt dan jadwal. Chat tertaut tidak berubah.",
+ "save": "Simpan",
"deleteTitle": "Hapus otomasi",
"deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.",
"cancel": "Batal",
"status": {
"system": "Sistem",
"pending": "Menunggu",
+ "running": "Sedang berjalan",
+ "needsSetup": "Perlu disiapkan",
"paused": "Dijeda",
"failed": "Gagal",
+ "completed": "Selesai",
+ "noSchedule": "Tanpa jadwal",
"active": "Aktif"
},
+ "legacyWarning": "Otomasi lama ini tidak memiliki chat tujuan. Buat ulang dari chat atau channel tempat otomasi akan berjalan.",
"origin": {
"system": "Sistem",
"unknown": "Tidak ada chat tertaut",
@@ -534,11 +550,16 @@
"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": "Segera berjalan",
+ "pending": "Sedang berjalan",
"none": "Tidak ada jadwal berikutnya"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "Selesai",
"error": "Error",
"skipped": "Dilewati",
- "recent": "Eksekusi terbaru"
+ "recent": "Eksekusi terbaru",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "Dibuat {{time}}",
+ "updated": "Diperbarui {{time}}"
+ },
+ "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."
},
"duration": {
"lessThanSecond": "kurang dari 1 detik"
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index f2ed06d3..5d7ad950 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "実行中",
"paused": "一時停止",
- "failed": "失敗",
+ "failed": "要対応",
"system": "システム"
},
"filters": {
"all": "すべて",
"active": "実行中",
"paused": "一時停止",
- "failed": "失敗",
+ "failed": "要対応",
"system": "システム"
},
+ "sort": {
+ "next": "次回実行",
+ "last": "前回実行",
+ "updated": "更新日時",
+ "name": "名前"
+ },
"search": "タスク、メッセージ、セッション、cron 式を検索",
"queue": "キュー",
"loading": "自動タスクを読み込み中...",
"noMatches": "この表示に一致する自動タスクはありません。",
"empty": "自動タスクはまだありません。",
+ "emptyHint": "実行先のチャットまたは外部 channel から作成すると、nanobot が正しいコンテキストを保持できます。",
"oneShot": "一回限り",
"systemTask": "システム管理の自動タスク",
"labels": {
@@ -495,18 +502,27 @@
"runNow": "今すぐ実行",
"pause": "一時停止",
"resume": "再開",
+ "edit": "編集",
"delete": "削除",
"protected": "保護済み",
+ "editTitle": "自動タスクを編集",
+ "editDescription": "プロンプトとスケジュールを更新します。関連チャットは変更されません。",
+ "save": "保存",
"deleteTitle": "自動タスクを削除",
"deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。",
"cancel": "キャンセル",
"status": {
"system": "システム",
"pending": "待機中",
+ "running": "実行中",
+ "needsSetup": "再設定が必要",
"paused": "一時停止",
"failed": "失敗",
+ "completed": "完了",
+ "noSchedule": "スケジュールなし",
"active": "実行中"
},
+ "legacyWarning": "この古い自動タスクには対象チャットがありません。実行先のチャットまたは外部 channel から作り直してください。",
"origin": {
"system": "システム",
"unknown": "関連チャットなし",
@@ -534,11 +550,16 @@
"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": "まもなく実行",
+ "pending": "実行中",
"none": "次回実行なし"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "完了",
"error": "エラー",
"skipped": "スキップ",
- "recent": "最近の実行"
+ "recent": "最近の実行",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "{{time}} 作成",
+ "updated": "{{time}} 更新"
+ },
+ "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": "実行日時は現在より後にしてください。"
},
"duration": {
"lessThanSecond": "1 秒未満"
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index 5780f2b1..a503c37f 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "활성",
"paused": "일시 중지",
- "failed": "실패",
+ "failed": "확인 필요",
"system": "시스템"
},
"filters": {
"all": "전체",
"active": "활성",
"paused": "일시 중지",
- "failed": "실패",
+ "failed": "확인 필요",
"system": "시스템"
},
+ "sort": {
+ "next": "다음 실행",
+ "last": "마지막 실행",
+ "updated": "업데이트",
+ "name": "이름"
+ },
"search": "작업, 메시지, 세션 또는 cron 식 검색",
"queue": "대기열",
"loading": "자동화를 불러오는 중...",
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
"empty": "아직 자동화가 없습니다.",
+ "emptyHint": "실행되어야 하는 채팅 또는 외부 channel에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
"oneShot": "일회성",
"systemTask": "시스템 관리 자동화",
"labels": {
@@ -495,18 +502,27 @@
"runNow": "지금 실행",
"pause": "일시 중지",
"resume": "재개",
+ "edit": "편집",
"delete": "삭제",
"protected": "보호됨",
+ "editTitle": "자동화 편집",
+ "editDescription": "프롬프트와 일정을 업데이트합니다. 연결된 채팅은 변경되지 않습니다.",
+ "save": "저장",
"deleteTitle": "자동화 삭제",
"deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.",
"cancel": "취소",
"status": {
"system": "시스템",
"pending": "대기 중",
+ "running": "실행 중",
+ "needsSetup": "설정 필요",
"paused": "일시 중지",
"failed": "실패",
+ "completed": "완료",
+ "noSchedule": "일정 없음",
"active": "활성"
},
+ "legacyWarning": "이전 자동화에 대상 채팅이 없습니다. 실행되어야 하는 채팅 또는 외부 channel에서 다시 만드세요.",
"origin": {
"system": "시스템",
"unknown": "연결된 채팅 없음",
@@ -534,11 +550,16 @@
"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": "곧 실행",
+ "pending": "실행 중",
"none": "다음 실행 없음"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "완료",
"error": "오류",
"skipped": "건너뜀",
- "recent": "최근 실행"
+ "recent": "최근 실행",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "{{time}} 생성",
+ "updated": "{{time}} 업데이트"
+ },
+ "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": "실행 시간은 현재보다 이후여야 합니다."
},
"duration": {
"lessThanSecond": "1초 미만"
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index 74f85ad5..5855b04f 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "Đang chạy",
"paused": "Đã tạm dừng",
- "failed": "Thất bại",
+ "failed": "Cần xử lý",
"system": "Hệ thống"
},
"filters": {
"all": "Tất cả",
"active": "Đang chạy",
"paused": "Đã tạm dừng",
- "failed": "Thất bại",
+ "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, phiên hoặc biểu thức cron",
"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ừ cuộc trò chuyện hoặc channel nơi tự động hóa 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": {
@@ -495,18 +502,27 @@
"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",
+ "editDescription": "Cập nhật prompt và lịch. Cuộc trò chuyện liên kết không thay đổi.",
+ "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",
"pending": "Đang chờ",
+ "running": "Đang chạy",
+ "needsSetup": "Cần thiết lập",
"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"
},
+ "legacyWarning": "Tự động hóa cũ này thiếu cuộc trò chuyện đích. Hãy tạo lại từ cuộc trò chuyện hoặc channel nơi nó sẽ chạy.",
"origin": {
"system": "Hệ thống",
"unknown": "Chưa liên kết cuộc trò chuyện",
@@ -534,11 +550,16 @@
"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": "Sắp chạy",
+ "pending": "Đang chạy",
"none": "Không có lần chạy tiếp theo"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "Hoàn tất",
"error": "Lỗi",
"skipped": "Đã bỏ qua",
- "recent": "Lượt chạy gần đây"
+ "recent": "Lượt chạy gần đây",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "Tạo lúc {{time}}",
+ "updated": "Cập nhật lúc {{time}}"
+ },
+ "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."
},
"duration": {
"lessThanSecond": "dưới 1 giây"
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 9f16b620..a1191795 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "运行中",
"paused": "已暂停",
- "failed": "失败",
+ "failed": "需处理",
"system": "系统"
},
"filters": {
"all": "全部",
"active": "运行中",
"paused": "已暂停",
- "failed": "失败",
+ "failed": "需处理",
"system": "系统"
},
+ "sort": {
+ "next": "下次运行",
+ "last": "上次运行",
+ "updated": "更新时间",
+ "name": "名称"
+ },
"search": "搜索任务、消息、会话或 cron 表达式",
"queue": "任务队列",
"loading": "正在加载自动任务...",
"noMatches": "当前视图没有匹配的自动任务。",
"empty": "暂无自动任务。",
+ "emptyHint": "请从它应该运行的聊天或外部 channel 中创建,这样 nanobot 才能保留正确上下文。",
"oneShot": "一次性",
"systemTask": "系统管理的自动任务",
"labels": {
@@ -495,18 +502,27 @@
"runNow": "立即运行",
"pause": "暂停",
"resume": "恢复",
+ "edit": "编辑",
"delete": "删除",
"protected": "受保护",
+ "editTitle": "编辑自动任务",
+ "editDescription": "更新提示词和计划;关联会话不会改变。",
+ "save": "保存",
"deleteTitle": "删除自动任务",
"deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。",
"cancel": "取消",
"status": {
"system": "系统",
"pending": "等待中",
+ "running": "正在运行",
+ "needsSetup": "需重新设置",
"paused": "已暂停",
"failed": "失败",
+ "completed": "已完成",
+ "noSchedule": "无计划",
"active": "运行中"
},
+ "legacyWarning": "这个旧版自动任务缺少目标会话。请从它应该运行的聊天或外部 channel 中重新创建。",
"origin": {
"system": "系统",
"unknown": "未关联会话",
@@ -534,11 +550,16 @@
"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": "即将运行",
+ "pending": "正在运行",
"none": "没有下次运行"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "完成",
"error": "错误",
"skipped": "已跳过",
- "recent": "最近运行"
+ "recent": "最近运行",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "创建于 {{time}}",
+ "updated": "更新于 {{time}}"
+ },
+ "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": "运行时间必须晚于当前时间。"
},
"duration": {
"lessThanSecond": "不到 1 秒"
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 9d5d168a..26baa44b 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -469,21 +469,28 @@
"stats": {
"active": "執行中",
"paused": "已暫停",
- "failed": "失敗",
+ "failed": "需處理",
"system": "系統"
},
"filters": {
"all": "全部",
"active": "執行中",
"paused": "已暫停",
- "failed": "失敗",
+ "failed": "需處理",
"system": "系統"
},
+ "sort": {
+ "next": "下次執行",
+ "last": "上次執行",
+ "updated": "更新時間",
+ "name": "名稱"
+ },
"search": "搜尋任務、訊息、會話或 cron 表達式",
"queue": "任務佇列",
"loading": "正在載入自動任務...",
"noMatches": "目前檢視沒有符合的自動任務。",
"empty": "尚無自動任務。",
+ "emptyHint": "請從它應該執行的聊天或外部 channel 中建立,這樣 nanobot 才能保留正確上下文。",
"oneShot": "一次性",
"systemTask": "系統管理的自動任務",
"labels": {
@@ -495,18 +502,27 @@
"runNow": "立即執行",
"pause": "暫停",
"resume": "恢復",
+ "edit": "編輯",
"delete": "刪除",
"protected": "受保護",
+ "editTitle": "編輯自動任務",
+ "editDescription": "更新提示詞和排程;關聯會話不會改變。",
+ "save": "儲存",
"deleteTitle": "刪除自動任務",
"deleteDescription": "這會從 cron 儲存中刪除 {{name}},歷史聊天訊息會保留在會話中。",
"cancel": "取消",
"status": {
"system": "系統",
"pending": "等待中",
+ "running": "正在執行",
+ "needsSetup": "需重新設定",
"paused": "已暫停",
"failed": "失敗",
+ "completed": "已完成",
+ "noSchedule": "無排程",
"active": "執行中"
},
+ "legacyWarning": "這個舊版自動任務缺少目標會話。請從它應該執行的聊天或外部 channel 中重新建立。",
"origin": {
"system": "系統",
"unknown": "未關聯會話",
@@ -534,11 +550,16 @@
"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": "即將執行",
+ "pending": "正在執行",
"none": "沒有下次執行"
},
"last": {
@@ -549,7 +570,41 @@
"ok": "完成",
"error": "錯誤",
"skipped": "已略過",
- "recent": "最近執行"
+ "recent": "最近執行",
+ "statusWithDuration": "{{status}} · {{duration}}"
+ },
+ "meta": {
+ "created": "建立於 {{time}}",
+ "updated": "更新於 {{time}}"
+ },
+ "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": "執行時間必須晚於目前時間。"
},
"duration": {
"lessThanSecond": "不到 1 秒"
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index 84854826..b31879e3 100644
--- a/webui/src/lib/api.ts
+++ b/webui/src/lib/api.ts
@@ -1,5 +1,6 @@
import type {
AutomationsPayload,
+ AutomationUpdatePayload,
ChatSummary,
CliAppsPayload,
FilePreviewPayload,
@@ -213,6 +214,26 @@ export async function runAutomationAction(
);
}
+export async function updateAutomation(
+ token: string,
+ id: string,
+ values: AutomationUpdatePayload,
+ base: string = "",
+): Promise {
+ const query = new URLSearchParams();
+ query.set("id", id);
+ return request(
+ `${base}/api/webui/automations/update?${query}`,
+ token,
+ {
+ headers: {
+ "X-Nanobot-Automation-Values": JSON.stringify(values),
+ },
+ },
+ API_READ_TIMEOUT_MS,
+ );
+}
+
export async function fetchSkills(
token: string,
base: string = "",
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 5cde8531..99d9c858 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -142,6 +142,17 @@ export interface SessionAutomationJob {
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;
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index da83639d..80619e73 100644
--- a/webui/src/tests/api.test.ts
+++ b/webui/src/tests/api.test.ts
@@ -25,6 +25,7 @@ import {
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
+ updateAutomation,
updateSidebarState,
updateImageGenerationSettings,
updateModelConfiguration,
@@ -123,6 +124,28 @@ describe("webui API helpers", () => {
);
});
+ it("serializes workspace automation updates", async () => {
+ await updateAutomation("tok", "job 1/2", {
+ name: "Daily quiz",
+ message: "Ask the quiz",
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
+ });
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/webui/automations/update?id=job+1%2F2",
+ expect.objectContaining({
+ headers: {
+ Authorization: "Bearer tok",
+ "X-Nanobot-Automation-Values": JSON.stringify({
+ name: "Daily quiz",
+ message: "Ask the quiz",
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
+ }),
+ },
+ }),
+ );
+ });
+
it("fetches the WebUI skill summary", async () => {
await fetchSkills("tok");