From e08462ca30e91e7afa3d2d5afd9535fe1dd5271a Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 13 Jun 2026 20:08:18 +0800 Subject: [PATCH] feat(webui): add automation management view --- nanobot/webui/session_automations.py | 120 +++- nanobot/webui/ws_http.py | 95 +++ tests/channels/test_websocket_http_routes.py | 85 +++ webui/src/App.tsx | 23 +- webui/src/components/Sidebar.tsx | 11 +- .../src/components/settings/SettingsView.tsx | 573 ++++++++++++++++++ webui/src/i18n/locales/en/common.json | 70 +++ webui/src/i18n/locales/es/common.json | 70 +++ webui/src/i18n/locales/fr/common.json | 70 +++ webui/src/i18n/locales/id/common.json | 70 +++ webui/src/i18n/locales/ja/common.json | 70 +++ webui/src/i18n/locales/ko/common.json | 70 +++ webui/src/i18n/locales/vi/common.json | 70 +++ webui/src/i18n/locales/zh-CN/common.json | 70 +++ webui/src/i18n/locales/zh-TW/common.json | 70 +++ webui/src/lib/api.ts | 29 + webui/src/lib/types.ts | 24 + webui/src/tests/api.test.ts | 24 + webui/src/tests/app-layout.test.tsx | 90 +++ webui/src/tests/i18n.test.tsx | 14 + 20 files changed, 1710 insertions(+), 8 deletions(-) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index c87dfd09..308ec3aa 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -1,4 +1,4 @@ -"""Session-scoped automation payloads for the embedded WebUI.""" +"""Automation payloads for the embedded WebUI.""" from __future__ import annotations @@ -9,6 +9,8 @@ from nanobot.cron.types import CronJob 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 +19,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 +51,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 +114,75 @@ 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, + "session_key": job.payload.session_key, + "origin_channel": job.payload.origin_channel, + "origin_chat_id": job.payload.origin_chat_id, + } + ) + 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: + 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 not session_key: + return None + + title = "" + preview = "" + 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")) + + channel, _, chat_id = session_key.partition(":") + 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 "" + for message in messages: + if not isinstance(message, dict): + continue + content = message.get("content") + if isinstance(content, str) and content.strip(): + return content.strip() + return "" diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 8d2694f6..787c141d 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -64,6 +64,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, @@ -236,6 +237,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 +520,95 @@ 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)$", 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 == "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) + 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: diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index fe74666c..10d56552 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -813,6 +813,91 @@ 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: + 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", + ) + cron.register_system_job( + CronJob( + id="heartbeat", + name="heartbeat", + schedule=CronSchedule(kind="every", every_ms=60_000), + payload=CronPayload(kind="system_event"), + ) + ) + channel = _ch( + bus, + session_manager=_seed_session(tmp_path, key="websocket:abc"), + cron_service=cron, + cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(), + port=29932, + ) + 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 + + boot = await _http_get("http://127.0.0.1:29932/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + resp = await _http_get( + "http://127.0.0.1:29932/api/webui/automations", + headers=auth, + ) + assert resp.status_code == 200 + 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 by_id["heartbeat"]["protected"] is True + + disabled = await _http_get( + f"http://127.0.0.1:29932/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 + + protected_delete = await _http_get( + "http://127.0.0.1:29932/api/webui/automations/delete?id=heartbeat", + headers=auth, + ) + assert protected_delete.status_code == 403 + + enabled = await _http_get( + f"http://127.0.0.1:29932/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"http://127.0.0.1:29932/api/webui/automations/delete?id={user_job.id}", + headers=auth, + ) + assert deleted.status_code == 200 + assert user_job.id not in {job["id"] for job in deleted.json()["jobs"]} + assert "heartbeat" in {job["id"] for job in deleted.json()["jobs"]} + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_session_delete_blocks_when_bound_automation_exists( bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/webui/src/App.tsx b/webui/src/App.tsx index fa297681..493f59da 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -71,7 +71,7 @@ const SIDEBAR_WIDTH = 272; const SIDEBAR_RAIL_WIDTH = 56; 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; @@ -86,6 +86,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [ "voice", "browser", "apps", + "automations", "skills", "runtime", "advanced", @@ -100,7 +101,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"; } @@ -129,6 +130,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" }; } @@ -1165,6 +1169,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" }); @@ -1340,6 +1350,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" }), @@ -1366,9 +1382,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, diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index f50275b3..b86ee57b 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react"; import { Archive, Brain, + CalendarClock, Menu, Search, Settings, @@ -36,8 +37,9 @@ interface SidebarProps { onOpenSettings: () => void; onOpenApps: () => void; onOpenSkills: () => void; + onOpenAutomations: () => void; onOpenSearch: () => void; - activeUtility?: "apps" | "skills" | null; + activeUtility?: "apps" | "skills" | "automations" | null; onToggleArchived: () => void; onCollapse: () => void; onExpand?: () => void; @@ -159,6 +161,13 @@ export function Sidebar(props: SidebarProps) { active={props.activeUtility === "apps"} icon={} /> + } + /> (() => initialSettings); const [cliApps, setCliApps] = useState(null); const [mcpPresets, setMcpPresets] = useState(null); + const [automations, setAutomations] = useState(null); const [loading, setLoading] = useState(() => initialSettings === null); const [cliAppsLoading, setCliAppsLoading] = useState(true); const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); + const [automationsLoading, setAutomationsLoading] = useState(false); const [saving, setSaving] = useState(false); const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false); const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false); @@ -533,12 +545,18 @@ export function SettingsView({ const [expandedProvider, setExpandedProvider] = useState(null); const [providerQuery, setProviderQuery] = useState(""); const [appsQuery, setAppsQuery] = useState(""); + const [automationsQuery, setAutomationsQuery] = useState(""); + const [automationsFilter, setAutomationsFilter] = useState("all"); const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); const [appsKindFilter, setAppsKindFilter] = useState("all"); const [mcpMessage, setMcpMessage] = useState(null); const [mcpError, setMcpError] = useState(null); + const [automationsError, setAutomationsError] = useState(null); + const [automationAction, setAutomationAction] = useState(null); + const [automationPendingDelete, setAutomationPendingDelete] = + useState(null); const [mcpFieldValues, setMcpFieldValues] = useState>>({}); const [customMcpForm, setCustomMcpForm] = useState(DEFAULT_CUSTOM_MCP_FORM); const [mcpConfigImport, setMcpConfigImport] = useState(""); @@ -701,6 +719,28 @@ export function SettingsView({ }; }, [activeSection, token]); + useEffect(() => { + if (activeSection !== "automations") return; + let cancelled = false; + setAutomationsLoading(true); + fetchAutomations(token) + .then((payload) => { + if (!cancelled) { + setAutomations(payload); + setAutomationsError(null); + } + }) + .catch((err) => { + if (!cancelled) setAutomationsError((err as Error).message); + }) + .finally(() => { + if (!cancelled) setAutomationsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [activeSection, token]); + useEffect(() => { try { window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs)); @@ -1225,6 +1265,36 @@ export function SettingsView({ } }; + const refreshAutomations = async () => { + setAutomationsLoading(true); + setAutomationsError(null); + try { + setAutomations(await fetchAutomations(token)); + } catch (err) { + setAutomationsError((err as Error).message); + } finally { + setAutomationsLoading(false); + } + }; + + const handleAutomationAction = async ( + action: AutomationAction, + job: SessionAutomationJob, + ) => { + const key = `${action}:${job.id}`; + setAutomationAction(key); + setAutomationsError(null); + try { + const payload = await runAutomationAction(token, action, job.id); + setAutomations(payload); + if (action === "delete") setAutomationPendingDelete(null); + } catch (err) { + setAutomationsError((err as Error).message); + } finally { + setAutomationAction(null); + } + }; + const handleMcpPresetAction = async ( action: "enable" | "remove" | "test", name: string, @@ -1505,6 +1575,22 @@ export function SettingsView({ isRestarting={isRestarting || hostEngineApplying} /> ); + case "automations": + return ( + + ); case "skills": return ; case "runtime": @@ -1563,6 +1649,15 @@ export function SettingsView({ onSave={handleCreateModelConfiguration} /> + { + if (!open) setAutomationPendingDelete(null); + }} + onConfirm={(job) => handleAutomationAction("delete", job)} + /> +
void; + onFilterChange: (value: AutomationFilter) => void; + onRefresh: () => void; + onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; + onRequestDelete: (job: SessionAutomationJob) => void; +}) { + const { t, i18n } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const jobs = payload?.jobs ?? []; + const normalizedQuery = query.trim().toLowerCase(); + const filtered = jobs + .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 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: "system", label: tx("settings.automations.filters.system", "System") }, + ]; + + return ( +
+
+
+
+
+ + {tx("settings.automations.kicker", "Workspace automations")} +
+

+ {tx("settings.automations.title", "Automations")} +

+

+ {tx( + "settings.automations.description", + "Review cron reminders, recurring agent turns, one-time jobs, and protected system automations in one place.", + )} +

+
+
+ + +
+
+ +
+ + + + +
+ +
+
+ + onQueryChange(event.target.value)} + placeholder={tx("settings.automations.search", "Search automation, message, session, or cron expression")} + className="h-9 rounded-full bg-background/85 pl-9 text-[13px]" + /> +
+ onFilterChange(value as AutomationFilter)} + /> +
+
+ + {error ? ( +
+ + {error} +
+ ) : null} + +
+ {tx("settings.automations.queue", "Queue")} + {loading && !payload ? ( +
+ + {tx("settings.automations.loading", "Loading automations...")} +
+ ) : filtered.length ? ( +
+ {filtered.map((job) => ( + + ))} +
+ ) : ( +
+ {jobs.length + ? tx("settings.automations.noMatches", "No automations match this view.") + : tx("settings.automations.empty", "No automations yet.")} +
+ )} +
+
+ ); +} + +function AutomationStat({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function AutomationRow({ + job, + locale, + actionKey, + onAction, + onRequestDelete, +}: { + job: SessionAutomationJob; + locale: string; + actionKey: string | null; + onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; + onRequestDelete: (job: SessionAutomationJob) => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const status = automationStatus(job, tx); + const origin = automationOriginLabel(job, tx); + const history = job.state.run_history ?? []; + const canManage = !job.protected; + const canRun = canManage && job.enabled && !job.state.pending; + const toggleAction: AutomationAction = job.enabled ? "disable" : "enable"; + const toggleBusy = actionKey === `${toggleAction}:${job.id}`; + + return ( +
+
+
+
+ + {job.name || job.id} + + {status.label} + {job.delete_after_run ? ( + {tx("settings.automations.oneShot", "One-time")} + ) : null} +
+

+ {job.payload.message || tx("settings.automations.systemTask", "System-managed automation")} +

+ +
+ + {formatAutomationSchedule(job, locale, tx)} + + + {formatAutomationNext(job, tx)} + + + {formatAutomationLast(job, locale, tx)} + + + {job.origin?.session_key ? ( + + {origin} + + + ) : ( + origin + )} + +
+ + {job.state.last_error ? ( +
+ {job.state.last_error} +
+ ) : null} + + {history.length ? ( +
+ {history.slice(-4).map((record) => ( + + {record.status} · {formatAutomationRunDuration(record.duration_ms)} + + ))} +
+ ) : null} +
+ +
+ {canManage ? ( + <> + void onAction("run", job)} + > + + + void onAction(toggleAction, job)} + > + {job.enabled ? ( + + ) : ( + + )} + + onRequestDelete(job)} + > + + + + ) : ( + + {tx("settings.automations.protected", "Protected")} + + )} +
+
+
+ ); +} + +function AutomationDetail({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function AutomationDeleteDialog({ + job, + deleting, + onOpenChange, + onConfirm, +}: { + job: SessionAutomationJob | null; + deleting: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (job: SessionAutomationJob) => void | Promise; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + return ( + + + + {tx("settings.automations.deleteTitle", "Delete automation")} + + {tx( + "settings.automations.deleteDescription", + "This removes {{name}} from the cron store. Past chat messages stay in the session.", + { name: job?.name || job?.id || "" }, + )} + + + + + + + + + ); +} + +function automationSearchText(job: SessionAutomationJob): string { + return [ + job.id, + job.name, + job.payload.message, + job.schedule.kind, + job.schedule.expr, + job.schedule.tz, + job.origin?.session_key, + job.origin?.title, + job.origin?.preview, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +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"; + if (filter === "system") return Boolean(job.protected); + return true; +} + +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") { + return { label: tx("settings.automations.status.failed", "Failed"), tone: "warning" }; + } + return { label: tx("settings.automations.status.active", "Active"), tone: "success" }; +} + +function automationOriginLabel( + job: SessionAutomationJob, + tx: (key: string, fallback: string, values?: Record) => string, +): string { + if (job.protected) return tx("settings.automations.origin.system", "System"); + const origin = job.origin; + if (!origin) return tx("settings.automations.origin.unknown", "Unknown session"); + return origin.title || origin.preview || origin.session_key; +} + +function formatAutomationSchedule( + job: SessionAutomationJob, + locale: string, + tx: (key: string, fallback: string, values?: Record) => string, +): string { + if (job.schedule.kind === "at" && job.schedule.at_ms) { + return tx("settings.automations.schedule.at", "At {{time}}", { + time: fmtDateTime(job.schedule.at_ms, locale), + }); + } + if (job.schedule.kind === "every" && job.schedule.every_ms) { + return tx("settings.automations.schedule.every", "Every {{duration}}", { + duration: formatAutomationInterval(job.schedule.every_ms), + }); + } + if (job.schedule.kind === "cron" && job.schedule.expr) { + return job.schedule.tz + ? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", { + expr: job.schedule.expr, + tz: job.schedule.tz, + }) + : tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr }); + } + return tx("settings.automations.schedule.custom", "Custom schedule"); +} + +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.next_run_at_ms) return tx("settings.automations.next.none", "No next run"); + return relativeTime(job.state.next_run_at_ms); +} + +function formatAutomationLast( + job: SessionAutomationJob, + locale: string, + tx: (key: string, fallback: string, values?: Record) => string, +): string { + if (!job.state.last_run_at_ms) return tx("settings.automations.last.never", "Never"); + const status = job.state.last_status || tx("settings.automations.last.unknown", "unknown"); + return `${fmtDateTime(job.state.last_run_at_ms, locale)} · ${status}`; +} + +function formatAutomationInterval(ms: number): string { + const units: Array<[string, number]> = [ + ["d", 86_400_000], + ["h", 3_600_000], + ["m", 60_000], + ["s", 1000], + ]; + for (const [suffix, size] of units) { + if (ms >= size && ms % size === 0) return `${ms / size}${suffix}`; + } + return `${Math.round(ms / 1000)}s`; +} + +function formatAutomationRunDuration(ms: number | undefined): string { + if (!ms || ms < 1000) return "<1s"; + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + return `${Math.round(ms / 60_000)}m`; +} + function AppsCatalogSettings({ cliApps, mcpPresets, diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 8020d348..fcdee641 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -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": { @@ -461,6 +463,74 @@ "loading": "Loading Apps...", "empty": "No apps match this filter." }, + "automations": { + "kicker": "Workspace automations", + "title": "Automations", + "description": "Review cron reminders, recurring agent turns, one-time jobs, and protected system automations in one place.", + "refresh": "Refresh", + "newInChat": "New in chat", + "stats": { + "active": "Active", + "paused": "Paused", + "failed": "Failed", + "system": "System" + }, + "filters": { + "all": "All", + "active": "Active", + "paused": "Paused", + "failed": "Failed", + "system": "System" + }, + "search": "Search automation, message, session, or cron expression", + "queue": "Queue", + "loading": "Loading automations...", + "noMatches": "No automations match this view.", + "empty": "No automations yet.", + "oneShot": "One-time", + "systemTask": "System-managed automation", + "labels": { + "schedule": "Schedule", + "next": "Next", + "last": "Last", + "origin": "Origin" + }, + "runNow": "Run now", + "pause": "Pause", + "resume": "Resume", + "delete": "Delete", + "protected": "Protected", + "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", + "paused": "Paused", + "failed": "Failed", + "active": "Active" + }, + "origin": { + "system": "System", + "unknown": "Unknown session" + }, + "schedule": { + "at": "At {{time}}", + "every": "Every {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "Custom schedule" + }, + "next": { + "paused": "Paused", + "pending": "Running soon", + "none": "No next run" + }, + "last": { + "never": "Never", + "unknown": "unknown" + } + }, "oauth": { "authentication": "OAuth authentication", "signIn": "Sign in", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 46a6c3ab..e0c091b0 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -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": { @@ -461,6 +463,74 @@ "loading": "Cargando apps...", "empty": "Ninguna app coincide con este filtro." }, + "automations": { + "kicker": "Automatizaciones del espacio", + "title": "Automatizaciones", + "description": "Revisa recordatorios cron, turnos recurrentes del agente, automatizaciones de una vez y automatizaciones protegidas del sistema en un solo lugar.", + "refresh": "Actualizar", + "newInChat": "Crear en chat", + "stats": { + "active": "Activas", + "paused": "Pausadas", + "failed": "Fallidas", + "system": "Sistema" + }, + "filters": { + "all": "Todas", + "active": "Activas", + "paused": "Pausadas", + "failed": "Fallidas", + "system": "Sistema" + }, + "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.", + "oneShot": "Una vez", + "systemTask": "Automatización administrada por el sistema", + "labels": { + "schedule": "Programación", + "next": "Siguiente", + "last": "Última", + "origin": "Origen" + }, + "runNow": "Ejecutar ahora", + "pause": "Pausar", + "resume": "Reanudar", + "delete": "Eliminar", + "protected": "Protegida", + "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", + "paused": "Pausada", + "failed": "Fallida", + "active": "Activa" + }, + "origin": { + "system": "Sistema", + "unknown": "Sesión desconocida" + }, + "schedule": { + "at": "A las {{time}}", + "every": "Cada {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "Programación personalizada" + }, + "next": { + "paused": "Pausada", + "pending": "Se ejecutará pronto", + "none": "Sin próxima ejecución" + }, + "last": { + "never": "Nunca", + "unknown": "desconocido" + } + }, "oauth": { "authentication": "Autenticación OAuth", "signIn": "Iniciar sesión", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 61182951..b788b6ce 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -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": { @@ -461,6 +463,74 @@ "loading": "Chargement des apps...", "empty": "Aucune app ne correspond." }, + "automations": { + "kicker": "Automatisations de l’espace", + "title": "Automatisations", + "description": "Passez en revue les rappels cron, tours récurrents de l’agent, automatisations ponctuelles et automatisations système protégées au même endroit.", + "refresh": "Actualiser", + "newInChat": "Créer dans le chat", + "stats": { + "active": "Actives", + "paused": "En pause", + "failed": "Échouées", + "system": "Système" + }, + "filters": { + "all": "Toutes", + "active": "Actives", + "paused": "En pause", + "failed": "Échouées", + "system": "Système" + }, + "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.", + "oneShot": "Ponctuelle", + "systemTask": "Automatisation gérée par le système", + "labels": { + "schedule": "Planning", + "next": "Prochaine", + "last": "Dernière", + "origin": "Origine" + }, + "runNow": "Exécuter maintenant", + "pause": "Mettre en pause", + "resume": "Reprendre", + "delete": "Supprimer", + "protected": "Protégée", + "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", + "paused": "En pause", + "failed": "Échouée", + "active": "En cours" + }, + "origin": { + "system": "Système", + "unknown": "Session inconnue" + }, + "schedule": { + "at": "À {{time}}", + "every": "Toutes les {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "Planning personnalisé" + }, + "next": { + "paused": "En pause", + "pending": "Exécution prochaine", + "none": "Aucune prochaine exécution" + }, + "last": { + "never": "Jamais", + "unknown": "inconnu" + } + }, "oauth": { "authentication": "Authentification OAuth", "signIn": "Se connecter", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 12b8f1af..68ea891f 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -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": { @@ -461,6 +463,74 @@ "loading": "Memuat aplikasi...", "empty": "Tidak ada aplikasi yang cocok." }, + "automations": { + "kicker": "Otomasi ruang kerja", + "title": "Otomasi", + "description": "Tinjau pengingat cron, giliran agen berulang, otomasi sekali jalan, dan otomasi sistem terlindungi di satu tempat.", + "refresh": "Segarkan", + "newInChat": "Buat di chat", + "stats": { + "active": "Aktif", + "paused": "Dijeda", + "failed": "Gagal", + "system": "Sistem" + }, + "filters": { + "all": "Semua", + "active": "Aktif", + "paused": "Dijeda", + "failed": "Gagal", + "system": "Sistem" + }, + "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.", + "oneShot": "Satu kali", + "systemTask": "Automasi yang dikelola sistem", + "labels": { + "schedule": "Jadwal", + "next": "Berikutnya", + "last": "Terakhir", + "origin": "Asal" + }, + "runNow": "Jalankan sekarang", + "pause": "Jeda", + "resume": "Lanjutkan", + "delete": "Hapus", + "protected": "Terlindungi", + "deleteTitle": "Hapus otomasi", + "deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.", + "cancel": "Batal", + "status": { + "system": "Sistem", + "pending": "Menunggu", + "paused": "Dijeda", + "failed": "Gagal", + "active": "Aktif" + }, + "origin": { + "system": "Sistem", + "unknown": "Sesi tidak dikenal" + }, + "schedule": { + "at": "Pada {{time}}", + "every": "Setiap {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "Jadwal khusus" + }, + "next": { + "paused": "Dijeda", + "pending": "Segera berjalan", + "none": "Tidak ada jadwal berikutnya" + }, + "last": { + "never": "Belum pernah", + "unknown": "tidak dikenal" + } + }, "oauth": { "authentication": "Autentikasi OAuth", "signIn": "Masuk", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 1c2a1962..3b0deab4 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -55,6 +55,7 @@ "ariaLabel": "言語を変更" }, "apps": "アプリ", + "automations": "自動タスク", "skills": { "title": "スキル" } @@ -80,6 +81,7 @@ "cliApps": "CLI アプリ", "mcp": "MCP", "apps": "アプリ", + "automations": "自動タスク", "skills": "スキル" }, "sections": { @@ -461,6 +463,74 @@ "loading": "アプリを読み込み中...", "empty": "一致するアプリはありません。" }, + "automations": { + "kicker": "ワークスペースの自動タスク", + "title": "自動タスク", + "description": "cron リマインダー、定期的な agent ターン、一回限りの自動タスク、保護されたシステム自動タスクを一か所で確認できます。", + "refresh": "更新", + "newInChat": "チャットで作成", + "stats": { + "active": "実行中", + "paused": "一時停止", + "failed": "失敗", + "system": "システム" + }, + "filters": { + "all": "すべて", + "active": "実行中", + "paused": "一時停止", + "failed": "失敗", + "system": "システム" + }, + "search": "タスク、メッセージ、セッション、cron 式を検索", + "queue": "キュー", + "loading": "自動タスクを読み込み中...", + "noMatches": "この表示に一致する自動タスクはありません。", + "empty": "自動タスクはまだありません。", + "oneShot": "一回限り", + "systemTask": "システム管理の自動タスク", + "labels": { + "schedule": "スケジュール", + "next": "次回", + "last": "前回", + "origin": "発生元" + }, + "runNow": "今すぐ実行", + "pause": "一時停止", + "resume": "再開", + "delete": "削除", + "protected": "保護済み", + "deleteTitle": "自動タスクを削除", + "deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。", + "cancel": "キャンセル", + "status": { + "system": "システム", + "pending": "待機中", + "paused": "一時停止", + "failed": "失敗", + "active": "実行中" + }, + "origin": { + "system": "システム", + "unknown": "不明なセッション" + }, + "schedule": { + "at": "{{time}}", + "every": "{{duration}} ごと", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "カスタムスケジュール" + }, + "next": { + "paused": "一時停止", + "pending": "まもなく実行", + "none": "次回実行なし" + }, + "last": { + "never": "未実行", + "unknown": "不明" + } + }, "oauth": { "authentication": "OAuth 認証", "signIn": "サインイン", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index e59c78cb..8dc5edef 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -55,6 +55,7 @@ "ariaLabel": "언어 변경" }, "apps": "앱", + "automations": "자동화", "skills": { "title": "스킬" } @@ -80,6 +81,7 @@ "cliApps": "CLI 앱", "mcp": "MCP", "apps": "앱", + "automations": "자동화", "skills": "스킬" }, "sections": { @@ -461,6 +463,74 @@ "loading": "앱을 불러오는 중...", "empty": "일치하는 앱이 없습니다." }, + "automations": { + "kicker": "작업 공간 자동화", + "title": "자동화", + "description": "cron 알림, 반복 agent 작업, 일회성 자동화, 보호된 시스템 자동화를 한곳에서 확인합니다.", + "refresh": "새로 고침", + "newInChat": "채팅에서 만들기", + "stats": { + "active": "활성", + "paused": "일시 중지", + "failed": "실패", + "system": "시스템" + }, + "filters": { + "all": "전체", + "active": "활성", + "paused": "일시 중지", + "failed": "실패", + "system": "시스템" + }, + "search": "작업, 메시지, 세션 또는 cron 식 검색", + "queue": "대기열", + "loading": "자동화를 불러오는 중...", + "noMatches": "이 보기와 일치하는 자동화가 없습니다.", + "empty": "아직 자동화가 없습니다.", + "oneShot": "일회성", + "systemTask": "시스템 관리 자동화", + "labels": { + "schedule": "일정", + "next": "다음", + "last": "마지막", + "origin": "출처" + }, + "runNow": "지금 실행", + "pause": "일시 중지", + "resume": "재개", + "delete": "삭제", + "protected": "보호됨", + "deleteTitle": "자동화 삭제", + "deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.", + "cancel": "취소", + "status": { + "system": "시스템", + "pending": "대기 중", + "paused": "일시 중지", + "failed": "실패", + "active": "활성" + }, + "origin": { + "system": "시스템", + "unknown": "알 수 없는 세션" + }, + "schedule": { + "at": "{{time}}", + "every": "{{duration}}마다", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "사용자 지정 일정" + }, + "next": { + "paused": "일시 중지", + "pending": "곧 실행", + "none": "다음 실행 없음" + }, + "last": { + "never": "실행된 적 없음", + "unknown": "알 수 없음" + } + }, "oauth": { "authentication": "OAuth 인증", "signIn": "로그인", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 22faba4e..325c2d8c 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -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": { @@ -461,6 +463,74 @@ "loading": "Đang tải ứng dụng...", "empty": "Không có ứng dụng phù hợp." }, + "automations": { + "kicker": "Tự động hóa không gian làm việc", + "title": "Tự động hóa", + "description": "Xem nhắc nhở cron, lượt agent định kỳ, tự động hóa một lần và tự động hóa hệ thống được bảo vệ tại một nơi.", + "refresh": "Làm mới", + "newInChat": "Tạo trong chat", + "stats": { + "active": "Đang chạy", + "paused": "Đã tạm dừng", + "failed": "Thất bại", + "system": "Hệ thống" + }, + "filters": { + "all": "Tất cả", + "active": "Đang chạy", + "paused": "Đã tạm dừng", + "failed": "Thất bại", + "system": "Hệ thống" + }, + "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.", + "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", + "last": "Lần trước", + "origin": "Nguồn" + }, + "runNow": "Chạy ngay", + "pause": "Tạm dừng", + "resume": "Tiếp tục", + "delete": "Xóa", + "protected": "Được bảo vệ", + "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ờ", + "paused": "Đã tạm dừng", + "failed": "Thất bại", + "active": "Đang chạy" + }, + "origin": { + "system": "Hệ thống", + "unknown": "Phiên không xác định" + }, + "schedule": { + "at": "Vào {{time}}", + "every": "Mỗi {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "Lịch tùy chỉnh" + }, + "next": { + "paused": "Đã tạm dừng", + "pending": "Sắp chạy", + "none": "Không có lần chạy tiếp theo" + }, + "last": { + "never": "Chưa từng chạy", + "unknown": "không xác định" + } + }, "oauth": { "authentication": "Xác thực OAuth", "signIn": "Đăng nhập", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 3ec1c3ac..47ec0c75 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -55,6 +55,7 @@ "ariaLabel": "切换语言" }, "apps": "应用", + "automations": "自动任务", "skills": { "title": "技能" } @@ -80,6 +81,7 @@ "runtime": "系统", "advanced": "安全", "apps": "应用", + "automations": "自动任务", "skills": "技能" }, "sections": { @@ -461,6 +463,74 @@ "loading": "正在加载应用...", "empty": "没有匹配的应用。" }, + "automations": { + "kicker": "工作区自动任务", + "title": "自动任务", + "description": "统一查看 cron 提醒、周期性 agent 任务、一次性自动任务和受保护的系统自动任务。", + "refresh": "刷新", + "newInChat": "在聊天中创建", + "stats": { + "active": "运行中", + "paused": "已暂停", + "failed": "失败", + "system": "系统" + }, + "filters": { + "all": "全部", + "active": "运行中", + "paused": "已暂停", + "failed": "失败", + "system": "系统" + }, + "search": "搜索任务、消息、会话或 cron 表达式", + "queue": "任务队列", + "loading": "正在加载自动任务...", + "noMatches": "当前视图没有匹配的自动任务。", + "empty": "暂无自动任务。", + "oneShot": "一次性", + "systemTask": "系统管理的自动任务", + "labels": { + "schedule": "计划", + "next": "下次", + "last": "上次", + "origin": "来源" + }, + "runNow": "立即运行", + "pause": "暂停", + "resume": "恢复", + "delete": "删除", + "protected": "受保护", + "deleteTitle": "删除自动任务", + "deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。", + "cancel": "取消", + "status": { + "system": "系统", + "pending": "等待中", + "paused": "已暂停", + "failed": "失败", + "active": "运行中" + }, + "origin": { + "system": "系统", + "unknown": "未知会话" + }, + "schedule": { + "at": "在 {{time}}", + "every": "每 {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "自定义计划" + }, + "next": { + "paused": "已暂停", + "pending": "即将运行", + "none": "没有下次运行" + }, + "last": { + "never": "从未运行", + "unknown": "未知" + } + }, "oauth": { "authentication": "OAuth 认证", "signIn": "登录", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 70f2a624..455865d0 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -55,6 +55,7 @@ "ariaLabel": "切換語言" }, "apps": "應用", + "automations": "自動任務", "skills": { "title": "技能" } @@ -80,6 +81,7 @@ "cliApps": "CLI 應用", "mcp": "MCP", "apps": "應用", + "automations": "自動任務", "skills": "技能" }, "sections": { @@ -461,6 +463,74 @@ "loading": "正在載入應用...", "empty": "沒有符合的應用。" }, + "automations": { + "kicker": "工作區自動任務", + "title": "自動任務", + "description": "集中查看 cron 提醒、週期性 agent 任務、一次性自動任務和受保護的系統自動任務。", + "refresh": "重新整理", + "newInChat": "在聊天中建立", + "stats": { + "active": "執行中", + "paused": "已暫停", + "failed": "失敗", + "system": "系統" + }, + "filters": { + "all": "全部", + "active": "執行中", + "paused": "已暫停", + "failed": "失敗", + "system": "系統" + }, + "search": "搜尋任務、訊息、會話或 cron 表達式", + "queue": "任務佇列", + "loading": "正在載入自動任務...", + "noMatches": "目前檢視沒有符合的自動任務。", + "empty": "尚無自動任務。", + "oneShot": "一次性", + "systemTask": "系統管理的自動任務", + "labels": { + "schedule": "排程", + "next": "下次", + "last": "上次", + "origin": "來源" + }, + "runNow": "立即執行", + "pause": "暫停", + "resume": "恢復", + "delete": "刪除", + "protected": "受保護", + "deleteTitle": "刪除自動任務", + "deleteDescription": "這會從 cron 儲存中刪除 {{name}},歷史聊天訊息會保留在會話中。", + "cancel": "取消", + "status": { + "system": "系統", + "pending": "等待中", + "paused": "已暫停", + "failed": "失敗", + "active": "執行中" + }, + "origin": { + "system": "系統", + "unknown": "未知會話" + }, + "schedule": { + "at": "於 {{time}}", + "every": "每 {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "custom": "自訂排程" + }, + "next": { + "paused": "已暫停", + "pending": "即將執行", + "none": "沒有下次執行" + }, + "last": { + "never": "從未執行", + "unknown": "未知" + } + }, "oauth": { "authentication": "OAuth 認證", "signIn": "登入", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index ec047c50..84854826 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,4 +1,5 @@ import type { + AutomationsPayload, ChatSummary, CliAppsPayload, FilePreviewPayload, @@ -184,6 +185,34 @@ export async function fetchSessionAutomations( ); } +export async function fetchAutomations( + token: string, + base: string = "", +): Promise { + return request( + `${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 { + const query = new URLSearchParams(); + query.set("id", id); + return request( + `${base}/api/webui/automations/${action}?${query}`, + token, + undefined, + 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 aa7461f3..5cde8531 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -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,35 @@ export interface SessionAutomationJob { }; payload: { message: string; + kind?: "agent_turn" | "system_event" | string; + session_key?: string | null; + origin_channel?: string | null; + origin_chat_id?: string | null; }; 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 SessionDeleteResult { deleted: boolean; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 7957b975..da83639d 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -4,6 +4,7 @@ import { createModelConfiguration, deleteSession, fetchFilePreview, + fetchAutomations, fetchCliApps, fetchInstalledCliApps, fetchMcpPresets, @@ -20,6 +21,7 @@ import { listSlashCommands, loginProviderOAuth, logoutProviderOAuth, + runAutomationAction, runCliAppAction, runMcpPresetAction, saveCustomMcpServer, @@ -99,6 +101,28 @@ 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("fetches the WebUI skill summary", async () => { await fetchSkills("tok"); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index d83d0996..f8c1496e 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -334,6 +334,96 @@ 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", + session_key: "websocket:chat-a", + }, + 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: "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(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + const automationsButton = within(sidebar).getByRole("button", { + name: "Automations", + }); + + fireEvent.click(automationsButton); + + expect(await screen.findByText("Workspace automations")).toBeInTheDocument(); + expect(screen.getByText("Daily repo check")).toBeInTheDocument(); + expect(screen.getByText("Check the repo status")).toBeInTheDocument(); + expect(screen.getByText("Release prep")).toBeInTheDocument(); + expect(screen.getByText("heartbeat")).toBeInTheDocument(); + expect(within(sidebar).getByRole("button", { name: "Automations" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(document.title).toBe("Automations · nanobot"); + }); + + it("localizes the Automations surface", async () => { + await i18n.changeLanguage("zh-CN"); + mockFetchRoutes({ + "/api/settings": baseSettingsPayload(), + "/api/webui/automations": { jobs: [] }, + }); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "自动任务" })); + + expect(await screen.findByText("工作区自动任务")).toBeInTheDocument(); + expect(screen.getAllByRole("heading", { name: "自动任务" }).length).toBeGreaterThan(0); + expect(screen.getByText("统一查看 cron 提醒、周期性 agent 任务、一次性自动任务和受保护的系统自动任务。")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "刷新" })).toBeInTheDocument(); + expect(screen.getByText("任务队列")).toBeInTheDocument(); + expect(screen.getByText("暂无自动任务。")).toBeInTheDocument(); + expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument(); + expect(document.title).toBe("自动任务 · nanobot"); + }); + it("fully collapses the native host sidebar and previews it on hover", async () => { mockSessions = [ { diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 99f1f0a0..444c7282 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -31,6 +31,7 @@ const SETTINGS_NAV_KEYS = [ "image", "browser", "apps", + "automations", "runtime", "advanced", ]; @@ -43,8 +44,21 @@ 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.title", + "settings.automations.description", + "settings.automations.refresh", + "settings.automations.newInChat", + "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",