From 747f0a08c7e435e082b32e655a2cb6bd56779387 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 14 Jun 2026 18:24:59 +0800 Subject: [PATCH] fix(webui): improve automation management --- nanobot/webui/ws_http.py | 86 ++- tests/channels/test_websocket_http_routes.py | 74 +- .../src/components/settings/SettingsView.tsx | 685 ++++++++++++++++-- webui/src/i18n/locales/en/common.json | 63 +- webui/src/i18n/locales/es/common.json | 63 +- webui/src/i18n/locales/fr/common.json | 63 +- webui/src/i18n/locales/id/common.json | 63 +- webui/src/i18n/locales/ja/common.json | 63 +- webui/src/i18n/locales/ko/common.json | 63 +- webui/src/i18n/locales/vi/common.json | 63 +- webui/src/i18n/locales/zh-CN/common.json | 63 +- webui/src/i18n/locales/zh-TW/common.json | 63 +- webui/src/lib/api.ts | 21 + webui/src/lib/types.ts | 11 + webui/src/tests/api.test.ts | 23 + 15 files changed, 1376 insertions(+), 91 deletions(-) diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 787c141d..e8b4445e 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -23,6 +23,7 @@ from websockets.http11 import Request as WsRequest from websockets.http11 import Response from nanobot.command.builtin import builtin_command_palette +from nanobot.cron.types import CronSchedule from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload @@ -80,6 +81,7 @@ from nanobot.webui.transcript import build_webui_thread_response from nanobot.webui.workspaces import WebUIWorkspaceController _SLOW_WEBUI_HTTP_LOG_MS = 1_000 +_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values" if TYPE_CHECKING: from nanobot.bus.queue import MessageBus @@ -529,7 +531,7 @@ class GatewayHTTPHandler: ) -> Response | None: if got == "/api/webui/automations": return self._handle_webui_automations(request) - m = re.match(r"^/api/webui/automations/(enable|disable|delete|run)$", got) + m = re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", got) if m: return await self._handle_webui_automation_action(request, m.group(1)) return None @@ -594,6 +596,21 @@ class GatewayHTTPHandler: return _http_error(409, "automation is disabled") task = asyncio.create_task(self.cron_service.run_job(job_id, force=False)) task.add_done_callback(self._log_automation_run_result) + elif action == "update": + values = _automation_values_from_request(request) + if values is None: + return _http_error(400, "invalid automation update payload") + parsed = _parse_automation_update(values) + if isinstance(parsed, str): + return _http_error(400, parsed) + try: + result = self.cron_service.update_job(job_id, **parsed) + except ValueError as exc: + return _http_error(400, str(exc)) + if result == "not_found": + return _http_error(404, "automation not found") + if result == "protected": + return _http_error(403, "system automation is protected") else: return _http_error(404, "unknown automation action") @@ -757,5 +774,72 @@ class GatewayHTTPHandler: extra_headers=[("Cache-Control", cache)], ) + +def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None: + raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER) + if not raw: + return {} + try: + values = json.loads(raw) + except Exception: + return None + return values if isinstance(values, dict) else None + + +def _parse_automation_update(values: dict[str, Any]) -> dict[str, Any] | str: + update: dict[str, Any] = {} + if "name" in values: + name = str(values.get("name") or "").strip() + if not name: + return "name cannot be empty" + update["name"] = name + if "message" in values: + message = str(values.get("message") or "").strip() + if not message: + return "message cannot be empty" + update["message"] = message + if "schedule" in values: + raw_schedule = values.get("schedule") + if not isinstance(raw_schedule, dict): + return "schedule must be an object" + parsed_schedule = _parse_automation_schedule(raw_schedule) + if isinstance(parsed_schedule, str): + return parsed_schedule + update["schedule"] = parsed_schedule + update["delete_after_run"] = parsed_schedule.kind == "at" + return update + + +def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str: + kind = str(values.get("kind") or "").strip() + if kind == "every": + every_ms = _positive_int(values.get("every_ms")) + if every_ms is None: + return "every schedule requires positive every_ms" + return CronSchedule(kind="every", every_ms=every_ms) + if kind == "cron": + expr = str(values.get("expr") or "").strip() + if not expr: + return "cron schedule requires expr" + tz = str(values.get("tz") or "").strip() or None + return CronSchedule(kind="cron", expr=expr, tz=tz) + if kind == "at": + at_ms = _positive_int(values.get("at_ms")) + if at_ms is None: + return "one-time schedule requires positive at_ms" + return CronSchedule(kind="at", at_ms=at_ms) + return "unknown schedule kind" + + +def _positive_int(value: Any) -> int | None: + if isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + def _is_websocket_channel_session_key(key: str) -> bool: return key.startswith("websocket:") diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 8cbbe80c..06912f69 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -3,6 +3,8 @@ import asyncio import functools import json +import random +import socket import threading import time from pathlib import Path @@ -23,6 +25,18 @@ from nanobot.webui.gateway_services import GatewayServices, build_gateway_servic _PORT = 29900 +def _free_port() -> int: + for _ in range(100): + port = random.randint(30_000, 60_000) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("127.0.0.1", port)) + except OSError: + continue + return port + raise RuntimeError("could not find a free localhost port") + + def _make_handler( cfg: dict[str, Any] | WebSocketConfig, bus: Any, @@ -817,6 +831,8 @@ async def test_session_delete_removes_file( async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( bus: MagicMock, tmp_path: Path ) -> None: + port = _free_port() + base_url = f"http://127.0.0.1:{port}" cron = CronService(tmp_path / "cron" / "jobs.json") user_job = cron.add_job( name="Daily repo check", @@ -857,19 +873,19 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( session_manager=session_manager, cron_service=cron, cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(), - port=29932, + port=port, ) server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - deny = await _http_get("http://127.0.0.1:29932/api/webui/automations") - assert deny.status_code == 401 + deny = await _http_get(f"{base_url}/api/webui/automations") + assert deny.status_code == 401, deny.text - boot = await _http_get("http://127.0.0.1:29932/webui/bootstrap") + boot = await _http_get(f"{base_url}/webui/bootstrap") token = boot.json()["token"] auth = {"Authorization": f"Bearer {token}"} resp = await _http_get( - "http://127.0.0.1:29932/api/webui/automations", + f"{base_url}/api/webui/automations", headers=auth, ) assert resp.status_code == 200 @@ -886,8 +902,42 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( assert by_id[external_job.id]["origin"]["preview"] == "" assert by_id["heartbeat"]["protected"] is True + updated = await _http_get( + f"{base_url}/api/webui/automations/update?id={user_job.id}", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps( + { + "name": "Daily quiz", + "message": "Ask the daily quiz", + "schedule": { + "kind": "cron", + "expr": "0 9 * * *", + "tz": "UTC", + }, + } + ), + }, + ) + assert updated.status_code == 200 + by_id = {job["id"]: job for job in updated.json()["jobs"]} + assert by_id[user_job.id]["name"] == "Daily quiz" + assert by_id[user_job.id]["payload"]["message"] == "Ask the daily quiz" + assert by_id[user_job.id]["schedule"]["kind"] == "cron" + assert by_id[user_job.id]["schedule"]["expr"] == "0 9 * * *" + assert by_id[user_job.id]["schedule"]["tz"] == "UTC" + + protected_update = await _http_get( + f"{base_url}/api/webui/automations/update?id=heartbeat", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps({"name": "bad"}), + }, + ) + assert protected_update.status_code == 403 + disabled = await _http_get( - f"http://127.0.0.1:29932/api/webui/automations/disable?id={user_job.id}", + f"{base_url}/api/webui/automations/disable?id={user_job.id}", headers=auth, ) assert disabled.status_code == 200 @@ -895,29 +945,29 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( assert by_id[user_job.id]["enabled"] is False disabled_run = await _http_get( - f"http://127.0.0.1:29932/api/webui/automations/run?id={user_job.id}", + f"{base_url}/api/webui/automations/run?id={user_job.id}", headers=auth, ) assert disabled_run.status_code == 409 protected_delete = await _http_get( - "http://127.0.0.1:29932/api/webui/automations/delete?id=heartbeat", + f"{base_url}/api/webui/automations/delete?id=heartbeat", headers=auth, ) assert protected_delete.status_code == 403 protected_disable = await _http_get( - "http://127.0.0.1:29932/api/webui/automations/disable?id=heartbeat", + f"{base_url}/api/webui/automations/disable?id=heartbeat", headers=auth, ) assert protected_disable.status_code == 403 protected_run = await _http_get( - "http://127.0.0.1:29932/api/webui/automations/run?id=heartbeat", + f"{base_url}/api/webui/automations/run?id=heartbeat", headers=auth, ) assert protected_run.status_code == 403 enabled = await _http_get( - f"http://127.0.0.1:29932/api/webui/automations/enable?id={user_job.id}", + f"{base_url}/api/webui/automations/enable?id={user_job.id}", headers=auth, ) assert enabled.status_code == 200 @@ -925,7 +975,7 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( assert by_id[user_job.id]["enabled"] is True deleted = await _http_get( - f"http://127.0.0.1:29932/api/webui/automations/delete?id={user_job.id}", + f"{base_url}/api/webui/automations/delete?id={user_job.id}", headers=auth, ) assert deleted.status_code == 200 diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 58d85737..2cf03b23 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -5,12 +5,14 @@ import { useMemo, useState, type Dispatch, + type FormEvent, type ReactNode, type SetStateAction, } from "react"; import { Activity, ArrowUpCircle, + ArrowUpDown, Bot, Brain, Check, @@ -93,6 +95,7 @@ import { runCliAppAction, runMcpPresetAction, saveCustomMcpServer, + updateAutomation, updateImageGenerationSettings, updateMcpServerTools, updateModelConfiguration, @@ -116,6 +119,7 @@ import { shortWorkspacePath } from "@/lib/workspace"; import { useClient } from "@/providers/ClientProvider"; import type { AutomationsPayload, + AutomationUpdatePayload, CliAppInfo, CliAppsPayload, ImageGenerationSettingsUpdate, @@ -148,6 +152,7 @@ type LocalDensity = "comfortable" | "compact"; type LocalActivityMode = "auto" | "expanded"; type AppsKindFilter = "all" | "cli" | "mcp"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; +type AutomationSort = "next" | "last" | "updated" | "name"; type AutomationAction = "enable" | "disable" | "delete" | "run"; type AppsCatalogItem = | { id: string; kind: "cli"; app: CliAppInfo } @@ -546,6 +551,7 @@ export function SettingsView({ const [appsQuery, setAppsQuery] = useState(""); const [automationsQuery, setAutomationsQuery] = useState(""); const [automationsFilter, setAutomationsFilter] = useState("all"); + const [automationsSort, setAutomationsSort] = useState("next"); const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); @@ -556,6 +562,8 @@ export function SettingsView({ const [automationAction, setAutomationAction] = useState(null); const [automationPendingDelete, setAutomationPendingDelete] = useState(null); + const [automationPendingEdit, setAutomationPendingEdit] = + useState(null); const [mcpFieldValues, setMcpFieldValues] = useState>>({}); const [customMcpForm, setCustomMcpForm] = useState(DEFAULT_CUSTOM_MCP_FORM); const [mcpConfigImport, setMcpConfigImport] = useState(""); @@ -718,25 +726,51 @@ export function SettingsView({ }; }, [activeSection, token]); + const refreshAutomations = useCallback( + async (showLoading = false) => { + if (showLoading) setAutomationsLoading(true); + try { + const payload = await fetchAutomations(token); + setAutomations(payload); + setAutomationsError(null); + } catch (err) { + setAutomationsError((err as Error).message); + } finally { + if (showLoading) setAutomationsLoading(false); + } + }, + [token], + ); + useEffect(() => { if (activeSection !== "automations") return; let cancelled = false; - setAutomationsLoading(true); - fetchAutomations(token) - .then((payload) => { - if (!cancelled) { - setAutomations(payload); - setAutomationsError(null); - } - }) - .catch((err) => { + const refresh = async (showLoading = false) => { + if (cancelled) return; + if (showLoading) setAutomationsLoading(true); + try { + const payload = await fetchAutomations(token); + if (cancelled) return; + setAutomations(payload); + setAutomationsError(null); + } catch (err) { if (!cancelled) setAutomationsError((err as Error).message); - }) - .finally(() => { - if (!cancelled) setAutomationsLoading(false); - }); + } finally { + if (!cancelled && showLoading) setAutomationsLoading(false); + } + }; + void refresh(true); + const interval = window.setInterval(() => void refresh(false), 5000); + const refreshOnFocus = () => { + if (document.visibilityState !== "hidden") void refresh(false); + }; + window.addEventListener("focus", refreshOnFocus); + document.addEventListener("visibilitychange", refreshOnFocus); return () => { cancelled = true; + window.clearInterval(interval); + window.removeEventListener("focus", refreshOnFocus); + document.removeEventListener("visibilitychange", refreshOnFocus); }; }, [activeSection, token]); @@ -1275,6 +1309,28 @@ export function SettingsView({ const payload = await runAutomationAction(token, action, job.id); setAutomations(payload); if (action === "delete") setAutomationPendingDelete(null); + if (action === "run") { + window.setTimeout(() => void refreshAutomations(false), 1200); + window.setTimeout(() => void refreshAutomations(false), 4000); + } + } catch (err) { + setAutomationsError((err as Error).message); + } finally { + setAutomationAction(null); + } + }; + + const handleAutomationEdit = async ( + job: SessionAutomationJob, + values: AutomationUpdatePayload, + ) => { + const key = `update:${job.id}`; + setAutomationAction(key); + setAutomationsError(null); + try { + const payload = await updateAutomation(token, job.id, values); + setAutomations(payload); + setAutomationPendingEdit(null); } catch (err) { setAutomationsError((err as Error).message); } finally { @@ -1569,11 +1625,14 @@ export function SettingsView({ loading={automationsLoading} query={automationsQuery} filter={automationsFilter} + sort={automationsSort} actionKey={automationAction} error={automationsError} onQueryChange={setAutomationsQuery} onFilterChange={setAutomationsFilter} + onSortChange={setAutomationsSort} onAction={handleAutomationAction} + onRequestEdit={setAutomationPendingEdit} onRequestDelete={setAutomationPendingDelete} /> ); @@ -1644,6 +1703,15 @@ export function SettingsView({ onConfirm={(job) => handleAutomationAction("delete", job)} /> + { + if (!open) setAutomationPendingEdit(null); + }} + onSave={handleAutomationEdit} + /> +
void; onFilterChange: (value: AutomationFilter) => void; + onSortChange: (value: AutomationSort) => void; onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; + onRequestEdit: (job: SessionAutomationJob) => void; onRequestDelete: (job: SessionAutomationJob) => void; }) { const { t, i18n } = useTranslation(); @@ -3356,20 +3430,29 @@ function AutomationsSettings({ t(key, { defaultValue: fallback, ...(values ?? {}) }); const jobs = payload?.jobs ?? []; const normalizedQuery = query.trim().toLowerCase(); - const filtered = jobs + const filtered = sortAutomationJobs(jobs, sort) .filter((job) => automationMatchesFilter(job, filter)) .filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery)); - const activeCount = jobs.filter((job) => job.enabled && !job.protected).length; - const pausedCount = jobs.filter((job) => !job.enabled && !job.protected).length; - const failedCount = jobs.filter((job) => job.state.last_status === "error").length; + const activeCount = jobs.filter((job) => { + const key = automationStatusKey(job); + return key === "active" || key === "running"; + }).length; + const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length; + const failedCount = jobs.filter(automationNeedsAttention).length; const systemCount = jobs.filter((job) => job.protected).length; const filterOptions = [ { value: "all", label: tx("settings.automations.filters.all", "All") }, { value: "active", label: tx("settings.automations.filters.active", "Active") }, { value: "paused", label: tx("settings.automations.filters.paused", "Paused") }, - { value: "failed", label: tx("settings.automations.filters.failed", "Failed") }, + { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention") }, { value: "system", label: tx("settings.automations.filters.system", "System") }, ]; + const sortLabel = { + next: tx("settings.automations.sort.next", "Next run"), + last: tx("settings.automations.sort.last", "Last run"), + updated: tx("settings.automations.sort.updated", "Updated"), + name: tx("settings.automations.sort.name", "Name"), + } satisfies Record; return (
@@ -3377,7 +3460,7 @@ function AutomationsSettings({
- +
@@ -3391,11 +3474,33 @@ function AutomationsSettings({ className="h-9 rounded-full bg-background/85 pl-9 text-[13px]" />
- onFilterChange(value as AutomationFilter)} - /> +
+ + + + + + {(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.", + )} + + + +
+ + +