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 01/38] 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", From 43830b7162b4904bea3fc53f6fdd36ec23efcfae Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 13 Jun 2026 21:30:31 +0800 Subject: [PATCH 02/38] fix(webui): localize automation runtime labels --- tests/channels/test_websocket_http_routes.py | 16 +++++ .../src/components/settings/SettingsView.tsx | 68 ++++++++++++++----- webui/src/i18n/locales/en/common.json | 8 +++ webui/src/i18n/locales/es/common.json | 8 +++ webui/src/i18n/locales/fr/common.json | 8 +++ webui/src/i18n/locales/id/common.json | 8 +++ webui/src/i18n/locales/ja/common.json | 8 +++ webui/src/i18n/locales/ko/common.json | 8 +++ webui/src/i18n/locales/vi/common.json | 8 +++ webui/src/i18n/locales/zh-CN/common.json | 8 +++ webui/src/i18n/locales/zh-TW/common.json | 8 +++ webui/src/tests/app-layout.test.tsx | 43 +++++++++++- webui/src/tests/i18n.test.tsx | 2 + 13 files changed, 181 insertions(+), 20 deletions(-) diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 10d56552..4767f9dd 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -872,11 +872,27 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( by_id = {job["id"]: job for job in disabled.json()["jobs"]} assert by_id[user_job.id]["enabled"] is False + disabled_run = await _http_get( + f"http://127.0.0.1:29932/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", 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", + 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", + 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}", diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index dbfe4947..c8c4fd93 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -3590,7 +3590,7 @@ function AutomationRow({ )} title={record.error || fmtDateTime(record.run_at_ms, locale)} > - {record.status} · {formatAutomationRunDuration(record.duration_ms)} + {automationRunStatusLabel(record.status, tx)} · {formatAutomationRunDuration(record.duration_ms, locale, tx)} ))}
@@ -3767,7 +3767,7 @@ function formatAutomationSchedule( } if (job.schedule.kind === "every" && job.schedule.every_ms) { return tx("settings.automations.schedule.every", "Every {{duration}}", { - duration: formatAutomationInterval(job.schedule.every_ms), + duration: formatAutomationInterval(job.schedule.every_ms, locale), }); } if (job.schedule.kind === "cron" && job.schedule.expr) { @@ -3797,27 +3797,59 @@ function formatAutomationLast( 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"); + const status = automationRunStatusLabel(job.state.last_status, tx); 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 automationRunStatusLabel( + status: string | null | undefined, + tx: (key: string, fallback: string, values?: Record) => string, +): string { + if (status === "ok") return tx("settings.automations.history.ok", "Completed"); + if (status === "error") return tx("settings.automations.history.error", "Error"); + if (status === "skipped") return tx("settings.automations.history.skipped", "Skipped"); + return status || tx("settings.automations.last.unknown", "unknown"); } -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 formatAutomationUnit( + value: number, + unit: Intl.NumberFormatOptions["unit"], + locale: string, + maximumFractionDigits = 0, +): string { + return new Intl.NumberFormat(locale, { + style: "unit", + unit, + unitDisplay: "long", + maximumFractionDigits, + }).format(value); +} + +function formatAutomationInterval(ms: number, locale: string): string { + const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [ + ["day", 86_400_000], + ["hour", 3_600_000], + ["minute", 60_000], + ["second", 1000], + ]; + for (const [unit, size] of units) { + if (ms >= size && ms % size === 0) return formatAutomationUnit(ms / size, unit, locale); + } + const fallbackUnit = ms < 60_000 ? "second" : "minute"; + const fallbackSize = fallbackUnit === "second" ? 1000 : 60_000; + return formatAutomationUnit(ms / fallbackSize, fallbackUnit, locale, 1); +} + +function formatAutomationRunDuration( + ms: number | undefined, + locale: string, + tx: (key: string, fallback: string, values?: Record) => string, +): string { + if (!ms || ms < 1000) { + return tx("settings.automations.duration.lessThanSecond", "< 1 second"); + } + if (ms < 60_000) return formatAutomationUnit(ms / 1000, "second", locale, 1); + return formatAutomationUnit(ms / 60_000, "minute", locale, 1); } function AppsCatalogSettings({ diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index fcdee641..04838947 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -529,6 +529,14 @@ "last": { "never": "Never", "unknown": "unknown" + }, + "history": { + "ok": "Completed", + "error": "Error", + "skipped": "Skipped" + }, + "duration": { + "lessThanSecond": "< 1 second" } }, "oauth": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index e0c091b0..c04429ee 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -529,6 +529,14 @@ "last": { "never": "Nunca", "unknown": "desconocido" + }, + "history": { + "ok": "Completada", + "error": "Error", + "skipped": "Omitida" + }, + "duration": { + "lessThanSecond": "menos de 1 segundo" } }, "oauth": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index b788b6ce..36fabcfe 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -529,6 +529,14 @@ "last": { "never": "Jamais", "unknown": "inconnu" + }, + "history": { + "ok": "Terminée", + "error": "Erreur", + "skipped": "Ignorée" + }, + "duration": { + "lessThanSecond": "moins de 1 seconde" } }, "oauth": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 68ea891f..522735d6 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -529,6 +529,14 @@ "last": { "never": "Belum pernah", "unknown": "tidak dikenal" + }, + "history": { + "ok": "Selesai", + "error": "Error", + "skipped": "Dilewati" + }, + "duration": { + "lessThanSecond": "kurang dari 1 detik" } }, "oauth": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 3b0deab4..57a3d0b1 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -529,6 +529,14 @@ "last": { "never": "未実行", "unknown": "不明" + }, + "history": { + "ok": "完了", + "error": "エラー", + "skipped": "スキップ" + }, + "duration": { + "lessThanSecond": "1 秒未満" } }, "oauth": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 8dc5edef..c1acc078 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -529,6 +529,14 @@ "last": { "never": "실행된 적 없음", "unknown": "알 수 없음" + }, + "history": { + "ok": "완료", + "error": "오류", + "skipped": "건너뜀" + }, + "duration": { + "lessThanSecond": "1초 미만" } }, "oauth": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 325c2d8c..1deddbac 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -529,6 +529,14 @@ "last": { "never": "Chưa từng chạy", "unknown": "không xác định" + }, + "history": { + "ok": "Hoàn tất", + "error": "Lỗi", + "skipped": "Đã bỏ qua" + }, + "duration": { + "lessThanSecond": "dưới 1 giây" } }, "oauth": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 47ec0c75..d6300380 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -529,6 +529,14 @@ "last": { "never": "从未运行", "unknown": "未知" + }, + "history": { + "ok": "完成", + "error": "错误", + "skipped": "已跳过" + }, + "duration": { + "lessThanSecond": "不到 1 秒" } }, "oauth": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 455865d0..4d1bab31 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -529,6 +529,14 @@ "last": { "never": "從未執行", "unknown": "未知" + }, + "history": { + "ok": "完成", + "error": "錯誤", + "skipped": "已略過" + }, + "duration": { + "lessThanSecond": "不到 1 秒" } }, "oauth": { diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index f8c1496e..a52099de 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -405,7 +405,43 @@ describe("App layout", () => { await i18n.changeLanguage("zh-CN"); mockFetchRoutes({ "/api/settings": baseSettingsPayload(), - "/api/webui/automations": { jobs: [] }, + "/api/webui/automations": { + jobs: [ + { + id: "job-zh", + name: "每日检查", + enabled: true, + protected: false, + delete_after_run: false, + schedule: { kind: "every", every_ms: 86_400_000 }, + payload: { + message: "检查仓库状态", + kind: "agent_turn", + session_key: "websocket:chat-a", + }, + state: { + next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0), + last_run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0), + last_status: "ok", + pending: false, + run_history: [ + { + run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0), + status: "ok", + duration_ms: 500, + }, + ], + }, + origin: { + session_key: "websocket:chat-a", + channel: "websocket", + chat_id: "chat-a", + title: "发布准备", + preview: "检查发布阻塞项", + }, + }, + ], + }, }); render(); @@ -419,7 +455,10 @@ describe("App layout", () => { expect(screen.getByText("统一查看 cron 提醒、周期性 agent 任务、一次性自动任务和受保护的系统自动任务。")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "刷新" })).toBeInTheDocument(); expect(screen.getByText("任务队列")).toBeInTheDocument(); - expect(screen.getByText("暂无自动任务。")).toBeInTheDocument(); + expect(screen.getByText("每日检查")).toBeInTheDocument(); + expect(screen.getByText("检查仓库状态")).toBeInTheDocument(); + expect(screen.getByText("每 1天")).toBeInTheDocument(); + expect(screen.getByText("完成 · 不到 1 秒")).toBeInTheDocument(); expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument(); expect(document.title).toBe("自动任务 · nanobot"); }); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 444c7282..a84e0b68 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -58,6 +58,8 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.automations.systemTask", "settings.automations.labels.schedule", "settings.automations.status.active", + "settings.automations.history.ok", + "settings.automations.duration.lessThanSecond", "settings.automations.deleteTitle", "settings.sections.interface", "settings.sections.localPreferences", From 5b10102629cc0c83ad7ba123f559d9fb7bd0a25b Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 14 Jun 2026 00:04:15 +0800 Subject: [PATCH 03/38] fix(webui): avoid fake automation origins --- nanobot/webui/session_automations.py | 9 ++++---- tests/channels/test_websocket_http_routes.py | 8 +++++++ .../src/components/settings/SettingsView.tsx | 14 +++++++---- webui/src/i18n/locales/en/common.json | 5 ++-- webui/src/i18n/locales/es/common.json | 5 ++-- webui/src/i18n/locales/fr/common.json | 5 ++-- webui/src/i18n/locales/id/common.json | 5 ++-- webui/src/i18n/locales/ja/common.json | 5 ++-- webui/src/i18n/locales/ko/common.json | 5 ++-- webui/src/i18n/locales/vi/common.json | 5 ++-- webui/src/i18n/locales/zh-CN/common.json | 5 ++-- webui/src/i18n/locales/zh-TW/common.json | 5 ++-- webui/src/tests/app-layout.test.tsx | 23 +++++++++++++++++++ 13 files changed, 72 insertions(+), 27 deletions(-) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index 308ec3aa..19340856 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -152,11 +152,11 @@ 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: + channel = job.payload.origin_channel + chat_id = job.payload.origin_chat_id + if not channel or not chat_id: return None + session_key = f"{channel}:{chat_id}" title = "" preview = "" @@ -166,7 +166,6 @@ def _origin_payload( 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, diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 4767f9dd..4ee76da9 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -826,6 +826,12 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( origin_channel="websocket", origin_chat_id="abc", ) + legacy_job = cron.add_job( + name="english-quiz", + schedule=CronSchedule(kind="every", every_ms=3_600_000), + message="Practice English", + session_key="unified:default", + ) cron.register_system_job( CronJob( id="heartbeat", @@ -862,6 +868,8 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( 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[legacy_job.id]["payload"]["session_key"] == "unified:default" + assert by_id[legacy_job.id]["origin"] is None assert by_id["heartbeat"]["protected"] is True disabled = await _http_get( diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index c8c4fd93..c9b3ef9d 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -3521,6 +3521,9 @@ function AutomationRow({ t(key, { defaultValue: fallback, ...(values ?? {}) }); const status = automationStatus(job, tx); const origin = automationOriginLabel(job, tx); + const originHref = job.origin?.channel === "websocket" + ? `#/chat/${encodeURIComponent(job.origin.session_key)}` + : null; const history = job.state.run_history ?? []; const canManage = !job.protected; const canRun = canManage && job.enabled && !job.state.pending; @@ -3554,11 +3557,11 @@ function AutomationRow({ {formatAutomationLast(job, locale, tx)} - - {job.origin?.session_key ? ( + + {originHref ? ( {origin} @@ -3751,7 +3754,10 @@ function automationOriginLabel( ): 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"); + if (!origin && job.payload.kind === "agent_turn") { + return tx("settings.automations.origin.legacy", "Recreate in target chat"); + } + if (!origin) return tx("settings.automations.origin.unknown", "No linked chat"); return origin.title || origin.preview || origin.session_key; } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 04838947..89b3ec99 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -493,7 +493,7 @@ "schedule": "Schedule", "next": "Next", "last": "Last", - "origin": "Origin" + "origin": "Linked chat" }, "runNow": "Run now", "pause": "Pause", @@ -512,7 +512,8 @@ }, "origin": { "system": "System", - "unknown": "Unknown session" + "unknown": "No linked chat", + "legacy": "Recreate in target chat" }, "schedule": { "at": "At {{time}}", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index c04429ee..c52a531c 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -493,7 +493,7 @@ "schedule": "Programación", "next": "Siguiente", "last": "Última", - "origin": "Origen" + "origin": "Chat vinculado" }, "runNow": "Ejecutar ahora", "pause": "Pausar", @@ -512,7 +512,8 @@ }, "origin": { "system": "Sistema", - "unknown": "Sesión desconocida" + "unknown": "Sin chat vinculado", + "legacy": "Recréala en el chat de destino" }, "schedule": { "at": "A las {{time}}", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 36fabcfe..6a6bb70e 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -493,7 +493,7 @@ "schedule": "Planning", "next": "Prochaine", "last": "Dernière", - "origin": "Origine" + "origin": "Discussion liée" }, "runNow": "Exécuter maintenant", "pause": "Mettre en pause", @@ -512,7 +512,8 @@ }, "origin": { "system": "Système", - "unknown": "Session inconnue" + "unknown": "Aucune discussion liée", + "legacy": "Recréez-la dans la discussion cible" }, "schedule": { "at": "À {{time}}", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 522735d6..9c49045b 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -493,7 +493,7 @@ "schedule": "Jadwal", "next": "Berikutnya", "last": "Terakhir", - "origin": "Asal" + "origin": "Chat tertaut" }, "runNow": "Jalankan sekarang", "pause": "Jeda", @@ -512,7 +512,8 @@ }, "origin": { "system": "Sistem", - "unknown": "Sesi tidak dikenal" + "unknown": "Tidak ada chat tertaut", + "legacy": "Buat ulang di chat tujuan" }, "schedule": { "at": "Pada {{time}}", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 57a3d0b1..34bc8262 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -493,7 +493,7 @@ "schedule": "スケジュール", "next": "次回", "last": "前回", - "origin": "発生元" + "origin": "関連チャット" }, "runNow": "今すぐ実行", "pause": "一時停止", @@ -512,7 +512,8 @@ }, "origin": { "system": "システム", - "unknown": "不明なセッション" + "unknown": "関連チャットなし", + "legacy": "対象チャットで作り直してください" }, "schedule": { "at": "{{time}}", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index c1acc078..1580a0ef 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -493,7 +493,7 @@ "schedule": "일정", "next": "다음", "last": "마지막", - "origin": "출처" + "origin": "연결된 채팅" }, "runNow": "지금 실행", "pause": "일시 중지", @@ -512,7 +512,8 @@ }, "origin": { "system": "시스템", - "unknown": "알 수 없는 세션" + "unknown": "연결된 채팅 없음", + "legacy": "대상 채팅에서 다시 만드세요" }, "schedule": { "at": "{{time}}", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 1deddbac..1d41a3f8 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -493,7 +493,7 @@ "schedule": "Lịch", "next": "Tiếp theo", "last": "Lần trước", - "origin": "Nguồn" + "origin": "Cuộc trò chuyện liên kết" }, "runNow": "Chạy ngay", "pause": "Tạm dừng", @@ -512,7 +512,8 @@ }, "origin": { "system": "Hệ thống", - "unknown": "Phiên không xác định" + "unknown": "Chưa liên kết cuộc trò chuyện", + "legacy": "Tạo lại trong cuộc trò chuyện đích" }, "schedule": { "at": "Vào {{time}}", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index d6300380..6d410477 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -493,7 +493,7 @@ "schedule": "计划", "next": "下次", "last": "上次", - "origin": "来源" + "origin": "关联会话" }, "runNow": "立即运行", "pause": "暂停", @@ -512,7 +512,8 @@ }, "origin": { "system": "系统", - "unknown": "未知会话" + "unknown": "未关联会话", + "legacy": "请在目标会话中重新创建" }, "schedule": { "at": "在 {{time}}", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 4d1bab31..8e59eeb0 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -493,7 +493,7 @@ "schedule": "排程", "next": "下次", "last": "上次", - "origin": "來源" + "origin": "關聯會話" }, "runNow": "立即執行", "pause": "暫停", @@ -512,7 +512,8 @@ }, "origin": { "system": "系統", - "unknown": "未知會話" + "unknown": "未關聯會話", + "legacy": "請在目標會話中重新建立" }, "schedule": { "at": "於 {{time}}", diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index a52099de..1c9f04fb 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -365,6 +365,26 @@ describe("App layout", () => { preview: "Check release blockers", }, }, + { + id: "legacy-quiz", + name: "english-quiz", + enabled: true, + protected: false, + delete_after_run: false, + schedule: { kind: "cron", expr: "30 9-23 * * *", tz: "Asia/Shanghai" }, + payload: { + message: "Practice English", + kind: "agent_turn", + session_key: "unified:default", + }, + state: { + next_run_at_ms: Date.UTC(2026, 3, 17, 11, 0, 0), + last_status: "ok", + pending: false, + run_history: [], + }, + origin: null, + }, { id: "heartbeat", name: "heartbeat", @@ -393,6 +413,9 @@ describe("App layout", () => { expect(screen.getByText("Daily repo check")).toBeInTheDocument(); expect(screen.getByText("Check the repo status")).toBeInTheDocument(); expect(screen.getByText("Release prep")).toBeInTheDocument(); + expect(screen.getByText("english-quiz")).toBeInTheDocument(); + expect(screen.getByText("Recreate in target chat")).toBeInTheDocument(); + expect(screen.queryByText("unified:default")).not.toBeInTheDocument(); expect(screen.getByText("heartbeat")).toBeInTheDocument(); expect(within(sidebar).getByRole("button", { name: "Automations" })).toHaveAttribute( "aria-current", From 17e31835981f2df93d2106e272e654708ccfe157 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 14 Jun 2026 00:32:53 +0800 Subject: [PATCH 04/38] fix(webui): simplify automation source display --- nanobot/webui/session_automations.py | 2 +- tests/channels/test_websocket_http_routes.py | 16 ++++- .../src/components/settings/SettingsView.tsx | 63 ++++++++++++------- webui/src/i18n/locales/en/common.json | 19 +++++- webui/src/i18n/locales/es/common.json | 19 +++++- webui/src/i18n/locales/fr/common.json | 19 +++++- webui/src/i18n/locales/id/common.json | 19 +++++- webui/src/i18n/locales/ja/common.json | 19 +++++- webui/src/i18n/locales/ko/common.json | 19 +++++- webui/src/i18n/locales/vi/common.json | 19 +++++- webui/src/i18n/locales/zh-CN/common.json | 19 +++++- webui/src/i18n/locales/zh-TW/common.json | 19 +++++- webui/src/tests/app-layout.test.tsx | 39 ++++++++++-- 13 files changed, 255 insertions(+), 36 deletions(-) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index 19340856..a63cf4da 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -160,7 +160,7 @@ def _origin_payload( title = "" preview = "" - if session_manager is not None: + if channel == "websocket" and session_manager is not None: data = session_manager.read_session_file(session_key) if isinstance(data, dict): title = str(data.get("title") or "") diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 4ee76da9..8cbbe80c 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -832,6 +832,14 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( message="Practice English", session_key="unified:default", ) + external_job = cron.add_job( + name="WeChat quiz", + schedule=CronSchedule(kind="every", every_ms=3_600_000), + message="Send a quiz", + session_key="weixin:wx-chat", + origin_channel="weixin", + origin_chat_id="wx-chat", + ) cron.register_system_job( CronJob( id="heartbeat", @@ -840,9 +848,13 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( payload=CronPayload(kind="system_event"), ) ) + session_manager = _seed_session(tmp_path, key="websocket:abc") + external_session = Session(key="weixin:wx-chat") + external_session.add_message("user", "Scheduled cron job triggered") + session_manager.save(external_session) channel = _ch( bus, - session_manager=_seed_session(tmp_path, key="websocket:abc"), + session_manager=session_manager, cron_service=cron, cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(), port=29932, @@ -870,6 +882,8 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( assert by_id[user_job.id]["origin"]["preview"] == "hi" assert by_id[legacy_job.id]["payload"]["session_key"] == "unified:default" assert by_id[legacy_job.id]["origin"] is None + assert by_id[external_job.id]["origin"]["session_key"] == "weixin:wx-chat" + assert by_id[external_job.id]["origin"]["preview"] == "" assert by_id["heartbeat"]["protected"] is True disabled = await _http_get( diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index c9b3ef9d..39e80249 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -13,7 +13,6 @@ import { ArrowUpCircle, Bot, Brain, - CalendarClock, Check, CircleAlert, ChevronDown, @@ -3390,23 +3389,14 @@ function AutomationsSettings({ 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.", - )} -

-
-
+
+

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

+
- -
+ -
+
@@ -3569,23 +3532,36 @@ function AutomationRow({ ) : null} {history.length ? ( -
- {history.slice(-4).map((record) => ( - - {automationRunStatusLabel(record.status, tx)} · {formatAutomationRunDuration(record.duration_ms, locale, tx)} - - ))} +
+
+ {tx("settings.automations.history.recent", "Recent runs")} +
+
+ {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 detail = record.error || fmtDateTime(record.run_at_ms, locale); + const accessibleLabel = `${statusLabel} · ${duration} · ${detail}`; + return ( + + {visibleLabel} + + ); + })} +
) : null}
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 4e98c346..47ef7a45 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -466,8 +466,6 @@ "automations": { "kicker": "Workspace automations", "title": "Automations", - "description": "Review cron reminders, recurring agent turns, one-time jobs, and protected system jobs.", - "refresh": "Refresh", "newInChat": "New in chat", "stats": { "active": "Active", @@ -551,7 +549,8 @@ "history": { "ok": "Completed", "error": "Error", - "skipped": "Skipped" + "skipped": "Skipped", + "recent": "Recent runs" }, "duration": { "lessThanSecond": "< 1 second" diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index efa66b9d..248f8ce5 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -466,8 +466,6 @@ "automations": { "kicker": "Automatizaciones del espacio", "title": "Automatizaciones", - "description": "Revisa recordatorios cron, turnos recurrentes del agente, tareas de una vez y tareas protegidas del sistema.", - "refresh": "Actualizar", "newInChat": "Crear en chat", "stats": { "active": "Activas", @@ -551,7 +549,8 @@ "history": { "ok": "Completada", "error": "Error", - "skipped": "Omitida" + "skipped": "Omitida", + "recent": "Ejecuciones recientes" }, "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 4c6eec9c..38baa058 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -466,8 +466,6 @@ "automations": { "kicker": "Automatisations de l’espace", "title": "Automatisations", - "description": "Passez en revue les rappels cron, tours récurrents de l’agent, tâches ponctuelles et tâches système protégées.", - "refresh": "Actualiser", "newInChat": "Créer dans le chat", "stats": { "active": "Actives", @@ -551,7 +549,8 @@ "history": { "ok": "Terminée", "error": "Erreur", - "skipped": "Ignorée" + "skipped": "Ignorée", + "recent": "Exécutions récentes" }, "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 1b1d0af6..9dd205d0 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -466,8 +466,6 @@ "automations": { "kicker": "Otomasi ruang kerja", "title": "Otomasi", - "description": "Tinjau pengingat cron, giliran agen berulang, tugas sekali jalan, dan tugas sistem terlindungi.", - "refresh": "Segarkan", "newInChat": "Buat di chat", "stats": { "active": "Aktif", @@ -551,7 +549,8 @@ "history": { "ok": "Selesai", "error": "Error", - "skipped": "Dilewati" + "skipped": "Dilewati", + "recent": "Eksekusi terbaru" }, "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 c1742924..b0472c74 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -466,8 +466,6 @@ "automations": { "kicker": "ワークスペースの自動タスク", "title": "自動タスク", - "description": "cron リマインダー、定期的な agent ターン、一回限りのタスク、保護されたシステムタスクを確認できます。", - "refresh": "更新", "newInChat": "チャットで作成", "stats": { "active": "実行中", @@ -551,7 +549,8 @@ "history": { "ok": "完了", "error": "エラー", - "skipped": "スキップ" + "skipped": "スキップ", + "recent": "最近の実行" }, "duration": { "lessThanSecond": "1 秒未満" diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 471cb78a..2a30e62e 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -466,8 +466,6 @@ "automations": { "kicker": "작업 공간 자동화", "title": "자동화", - "description": "cron 알림, 반복 agent 작업, 일회성 작업, 보호된 시스템 작업을 확인합니다.", - "refresh": "새로 고침", "newInChat": "채팅에서 만들기", "stats": { "active": "활성", @@ -551,7 +549,8 @@ "history": { "ok": "완료", "error": "오류", - "skipped": "건너뜀" + "skipped": "건너뜀", + "recent": "최근 실행" }, "duration": { "lessThanSecond": "1초 미만" diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index ba9b8beb..942d9abf 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -466,8 +466,6 @@ "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ác vụ một lần và tác vụ hệ thống được bảo vệ.", - "refresh": "Làm mới", "newInChat": "Tạo trong chat", "stats": { "active": "Đang chạy", @@ -551,7 +549,8 @@ "history": { "ok": "Hoàn tất", "error": "Lỗi", - "skipped": "Đã bỏ qua" + "skipped": "Đã bỏ qua", + "recent": "Lượt chạy gần đây" }, "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 2e9e35c7..986b8673 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -466,8 +466,6 @@ "automations": { "kicker": "工作区自动任务", "title": "自动任务", - "description": "统一查看 cron 提醒、周期性 agent 任务、一次性任务和系统任务。", - "refresh": "刷新", "newInChat": "在聊天中创建", "stats": { "active": "运行中", @@ -551,7 +549,8 @@ "history": { "ok": "完成", "error": "错误", - "skipped": "已跳过" + "skipped": "已跳过", + "recent": "最近运行" }, "duration": { "lessThanSecond": "不到 1 秒" diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 6a1d223c..23de0541 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -466,8 +466,6 @@ "automations": { "kicker": "工作區自動任務", "title": "自動任務", - "description": "集中查看 cron 提醒、週期性 agent 任務、一次性任務和系統任務。", - "refresh": "重新整理", "newInChat": "在聊天中建立", "stats": { "active": "執行中", @@ -551,7 +549,8 @@ "history": { "ok": "完成", "error": "錯誤", - "skipped": "已略過" + "skipped": "已略過", + "recent": "最近執行" }, "duration": { "lessThanSecond": "不到 1 秒" diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 34a11bb8..7e858b62 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -506,14 +506,14 @@ describe("App layout", () => { fireEvent.click(within(sidebar).getByRole("button", { name: "自动任务" })); expect(await screen.findByRole("heading", { name: "自动任务" })).toBeInTheDocument(); - expect(screen.getByText("统一查看 cron 提醒、周期性 agent 任务、一次性任务和系统任务。")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "刷新" })).toBeInTheDocument(); expect(screen.getByText("任务队列")).toBeInTheDocument(); expect(screen.getByText("每日检查")).toBeInTheDocument(); expect(screen.getByText("检查仓库状态")).toBeInTheDocument(); expect(screen.getByText("每 1天")).toBeInTheDocument(); - expect(screen.getByText("完成 · 不到 1 秒")).toBeInTheDocument(); + expect(screen.getByText("最近运行")).toBeInTheDocument(); + expect(screen.getByText("不到 1 秒")).toBeInTheDocument(); expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument(); expect(document.title).toBe("自动任务 · nanobot"); }); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index a84e0b68..e185b87b 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -49,8 +49,6 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.nav.advanced", "sidebar.automations", "settings.automations.title", - "settings.automations.description", - "settings.automations.refresh", "settings.automations.newInChat", "settings.automations.filters.active", "settings.automations.queue", @@ -59,6 +57,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.automations.labels.schedule", "settings.automations.status.active", "settings.automations.history.ok", + "settings.automations.history.recent", "settings.automations.duration.lessThanSecond", "settings.automations.deleteTitle", "settings.sections.interface", From 6cf1f8e1645ab521559d54f2dc6361bfc3e2f03a Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 14 Jun 2026 02:10:16 +0800 Subject: [PATCH 06/38] fix(webui): trim automation creation prompt --- webui/src/components/settings/SettingsView.tsx | 11 +---------- webui/src/i18n/locales/en/common.json | 1 - webui/src/i18n/locales/es/common.json | 1 - webui/src/i18n/locales/fr/common.json | 1 - webui/src/i18n/locales/id/common.json | 1 - webui/src/i18n/locales/ja/common.json | 1 - webui/src/i18n/locales/ko/common.json | 1 - webui/src/i18n/locales/vi/common.json | 1 - webui/src/i18n/locales/zh-CN/common.json | 1 - webui/src/i18n/locales/zh-TW/common.json | 1 - webui/src/tests/i18n.test.tsx | 1 - 11 files changed, 1 insertion(+), 20 deletions(-) diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index fc2514a0..58d85737 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -3374,16 +3374,7 @@ function AutomationsSettings({ return (
- - -
+
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 47ef7a45..10295173 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "Workspace automations", "title": "Automations", - "newInChat": "New in chat", "stats": { "active": "Active", "paused": "Paused", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 248f8ce5..d1f793de 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "Automatizaciones del espacio", "title": "Automatizaciones", - "newInChat": "Crear en chat", "stats": { "active": "Activas", "paused": "Pausadas", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 38baa058..992be5ed 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "Automatisations de l’espace", "title": "Automatisations", - "newInChat": "Créer dans le chat", "stats": { "active": "Actives", "paused": "En pause", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 9dd205d0..01ed7a93 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "Otomasi ruang kerja", "title": "Otomasi", - "newInChat": "Buat di chat", "stats": { "active": "Aktif", "paused": "Dijeda", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index b0472c74..f2ed06d3 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "ワークスペースの自動タスク", "title": "自動タスク", - "newInChat": "チャットで作成", "stats": { "active": "実行中", "paused": "一時停止", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 2a30e62e..5780f2b1 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "작업 공간 자동화", "title": "자동화", - "newInChat": "채팅에서 만들기", "stats": { "active": "활성", "paused": "일시 중지", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 942d9abf..74f85ad5 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "Tự động hóa không gian làm việc", "title": "Tự động hóa", - "newInChat": "Tạo trong chat", "stats": { "active": "Đang chạy", "paused": "Đã tạm dừng", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 986b8673..9f16b620 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "工作区自动任务", "title": "自动任务", - "newInChat": "在聊天中创建", "stats": { "active": "运行中", "paused": "已暂停", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 23de0541..9d5d168a 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -466,7 +466,6 @@ "automations": { "kicker": "工作區自動任務", "title": "自動任務", - "newInChat": "在聊天中建立", "stats": { "active": "執行中", "paused": "已暫停", diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index e185b87b..b15c13da 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -49,7 +49,6 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.nav.advanced", "sidebar.automations", "settings.automations.title", - "settings.automations.newInChat", "settings.automations.filters.active", "settings.automations.queue", "settings.automations.empty", 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 07/38] 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)} - /> +
+ + + + + + {(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 ? ( + +
+ + {tx("settings.automations.editTitle", "Edit automation")} + + {tx( + "settings.automations.editDescription", + "Update the prompt and schedule. The linked chat stays unchanged.", + )} + + + +
+ + +