feat(webui): add automation management view
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
"""Session-scoped automation payloads for the embedded WebUI."""
|
"""Automation payloads for the embedded WebUI."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ from nanobot.cron.types import CronJob
|
|||||||
|
|
||||||
|
|
||||||
class _CronServiceLike(Protocol):
|
class _CronServiceLike(Protocol):
|
||||||
|
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: ...
|
||||||
|
|
||||||
def list_bound_cron_jobs_for_session(
|
def list_bound_cron_jobs_for_session(
|
||||||
self,
|
self,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
@@ -17,6 +19,10 @@ class _CronServiceLike(Protocol):
|
|||||||
) -> list[CronJob]: ...
|
) -> list[CronJob]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionManagerLike(Protocol):
|
||||||
|
def read_session_file(self, key: str) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
|
|
||||||
def session_automation_jobs(
|
def session_automation_jobs(
|
||||||
cron_service: _CronServiceLike | None,
|
cron_service: _CronServiceLike | None,
|
||||||
session_key: str,
|
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(
|
def serialize_automation_jobs(
|
||||||
jobs: list[CronJob],
|
jobs: list[CronJob],
|
||||||
*,
|
*,
|
||||||
pending_job_ids: Collection[str] | None = None,
|
pending_job_ids: Collection[str] | None = None,
|
||||||
|
include_details: bool = False,
|
||||||
|
session_manager: _SessionManagerLike | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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]:
|
def _serialize_job(
|
||||||
return {
|
job: CronJob,
|
||||||
|
*,
|
||||||
|
pending: bool = False,
|
||||||
|
include_details: bool = False,
|
||||||
|
session_manager: _SessionManagerLike | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
"id": job.id,
|
"id": job.id,
|
||||||
"name": job.name,
|
"name": job.name,
|
||||||
"enabled": job.enabled,
|
"enabled": job.enabled,
|
||||||
@@ -74,3 +114,75 @@ def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
|
|||||||
"pending": pending,
|
"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 ""
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ from nanobot.webui.http_utils import (
|
|||||||
)
|
)
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
from nanobot.webui.session_automations import (
|
from nanobot.webui.session_automations import (
|
||||||
|
all_automations_payload,
|
||||||
serialize_automation_jobs,
|
serialize_automation_jobs,
|
||||||
session_automation_jobs,
|
session_automation_jobs,
|
||||||
session_automations_payload,
|
session_automations_payload,
|
||||||
@@ -236,6 +237,11 @@ class GatewayHTTPHandler:
|
|||||||
if response is not None:
|
if response is not None:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# Automation routes
|
||||||
|
response = await self._dispatch_automation_routes(request, got)
|
||||||
|
if response is not None:
|
||||||
|
return response
|
||||||
|
|
||||||
# Misc routes
|
# Misc routes
|
||||||
response = await self._dispatch_misc_routes(connection, request, got)
|
response = await self._dispatch_misc_routes(connection, request, got)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
@@ -514,6 +520,95 @@ class GatewayHTTPHandler:
|
|||||||
delete_webui_thread(decoded_key)
|
delete_webui_thread(decoded_key)
|
||||||
return _http_json_response({"deleted": bool(deleted)})
|
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 -------------------------------------------------------
|
# -- Media routes -------------------------------------------------------
|
||||||
|
|
||||||
def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None:
|
def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||||
|
|||||||
@@ -813,6 +813,91 @@ async def test_session_delete_removes_file(
|
|||||||
await server_task
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_session_delete_blocks_when_bound_automation_exists(
|
async def test_session_delete_blocks_when_bound_automation_exists(
|
||||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
|||||||
+20
-3
@@ -71,7 +71,7 @@ const SIDEBAR_WIDTH = 272;
|
|||||||
const SIDEBAR_RAIL_WIDTH = 56;
|
const SIDEBAR_RAIL_WIDTH = 56;
|
||||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||||
type ShellView = "chat" | "settings" | "apps" | "skills";
|
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
|
||||||
type ShellRoute = {
|
type ShellRoute = {
|
||||||
view: ShellView;
|
view: ShellView;
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
@@ -86,6 +86,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
|
|||||||
"voice",
|
"voice",
|
||||||
"browser",
|
"browser",
|
||||||
"apps",
|
"apps",
|
||||||
|
"automations",
|
||||||
"skills",
|
"skills",
|
||||||
"runtime",
|
"runtime",
|
||||||
"advanced",
|
"advanced",
|
||||||
@@ -100,7 +101,7 @@ function defaultShellRoute(): ShellRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
|
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
|
||||||
if (section === "apps" || section === "skills") return section;
|
if (section === "apps" || section === "automations" || section === "skills") return section;
|
||||||
return "settings";
|
return "settings";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +130,9 @@ function readShellRoute(): ShellRoute {
|
|||||||
if (path === "/apps") {
|
if (path === "/apps") {
|
||||||
return { view: "apps", activeKey, settingsSection: "apps" };
|
return { view: "apps", activeKey, settingsSection: "apps" };
|
||||||
}
|
}
|
||||||
|
if (path === "/automations") {
|
||||||
|
return { view: "automations", activeKey, settingsSection: "automations" };
|
||||||
|
}
|
||||||
if (path === "/skills") {
|
if (path === "/skills") {
|
||||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||||
}
|
}
|
||||||
@@ -1165,6 +1169,12 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, [activeKey, navigate]);
|
}, [activeKey, navigate]);
|
||||||
|
|
||||||
|
const onOpenAutomations = useCallback(() => {
|
||||||
|
setSessionSearchOpen(false);
|
||||||
|
navigate({ view: "automations", activeKey, settingsSection: "automations" });
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}, [activeKey, navigate]);
|
||||||
|
|
||||||
const onOpenSkills = useCallback(() => {
|
const onOpenSkills = useCallback(() => {
|
||||||
setSessionSearchOpen(false);
|
setSessionSearchOpen(false);
|
||||||
navigate({ view: "skills", activeKey, settingsSection: "skills" });
|
navigate({ view: "skills", activeKey, settingsSection: "skills" });
|
||||||
@@ -1340,6 +1350,12 @@ function Shell({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (view === "automations") {
|
||||||
|
document.title = t("app.documentTitle.chat", {
|
||||||
|
title: t("settings.nav.automations", { defaultValue: "Automations" }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (view === "skills") {
|
if (view === "skills") {
|
||||||
document.title = t("app.documentTitle.chat", {
|
document.title = t("app.documentTitle.chat", {
|
||||||
title: t("settings.nav.skills", { defaultValue: "Skills" }),
|
title: t("settings.nav.skills", { defaultValue: "Skills" }),
|
||||||
@@ -1366,9 +1382,10 @@ function Shell({
|
|||||||
onNewChatInProject,
|
onNewChatInProject,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenApps,
|
onOpenApps,
|
||||||
|
onOpenAutomations,
|
||||||
onOpenSkills,
|
onOpenSkills,
|
||||||
onOpenSearch: onOpenSessionSearch,
|
onOpenSearch: onOpenSessionSearch,
|
||||||
activeUtility: view === "apps" || view === "skills" ? view : null,
|
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
||||||
onToggleArchived,
|
onToggleArchived,
|
||||||
pinnedKeys: sidebarState.pinned_keys,
|
pinnedKeys: sidebarState.pinned_keys,
|
||||||
archivedKeys: sidebarState.archived_keys,
|
archivedKeys: sidebarState.archived_keys,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
|
|||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
Brain,
|
Brain,
|
||||||
|
CalendarClock,
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -36,8 +37,9 @@ interface SidebarProps {
|
|||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onOpenApps: () => void;
|
onOpenApps: () => void;
|
||||||
onOpenSkills: () => void;
|
onOpenSkills: () => void;
|
||||||
|
onOpenAutomations: () => void;
|
||||||
onOpenSearch: () => void;
|
onOpenSearch: () => void;
|
||||||
activeUtility?: "apps" | "skills" | null;
|
activeUtility?: "apps" | "skills" | "automations" | null;
|
||||||
onToggleArchived: () => void;
|
onToggleArchived: () => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
onExpand?: () => void;
|
onExpand?: () => void;
|
||||||
@@ -159,6 +161,13 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
active={props.activeUtility === "apps"}
|
active={props.activeUtility === "apps"}
|
||||||
icon={<Blocks className="h-4 w-4" />}
|
icon={<Blocks className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
|
<SidebarActionButton
|
||||||
|
collapsed={collapsed}
|
||||||
|
label={t("sidebar.automations", { defaultValue: "Automations" })}
|
||||||
|
onClick={props.onOpenAutomations}
|
||||||
|
active={props.activeUtility === "automations"}
|
||||||
|
icon={<CalendarClock className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
<SidebarActionButton
|
<SidebarActionButton
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
label={t("sidebar.skills.title")}
|
label={t("sidebar.skills.title")}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
Bot,
|
Bot,
|
||||||
Brain,
|
Brain,
|
||||||
|
CalendarClock,
|
||||||
Check,
|
Check,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Mic,
|
Mic,
|
||||||
Moon,
|
Moon,
|
||||||
|
PauseCircle,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
Orbit,
|
Orbit,
|
||||||
@@ -79,6 +81,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import {
|
import {
|
||||||
checkVersion,
|
checkVersion,
|
||||||
createModelConfiguration,
|
createModelConfiguration,
|
||||||
|
fetchAutomations,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
fetchSettingsUsage,
|
fetchSettingsUsage,
|
||||||
fetchCliApps,
|
fetchCliApps,
|
||||||
@@ -87,6 +90,7 @@ import {
|
|||||||
importMcpConfig,
|
importMcpConfig,
|
||||||
loginProviderOAuth,
|
loginProviderOAuth,
|
||||||
logoutProviderOAuth,
|
logoutProviderOAuth,
|
||||||
|
runAutomationAction,
|
||||||
runCliAppAction,
|
runCliAppAction,
|
||||||
runMcpPresetAction,
|
runMcpPresetAction,
|
||||||
saveCustomMcpServer,
|
saveCustomMcpServer,
|
||||||
@@ -102,6 +106,7 @@ import {
|
|||||||
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
||||||
import { getHostApi } from "@/lib/runtime";
|
import { getHostApi } from "@/lib/runtime";
|
||||||
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
||||||
|
import { fmtDateTime, relativeTime } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
logoFallbackUrls,
|
logoFallbackUrls,
|
||||||
providerBrand,
|
providerBrand,
|
||||||
@@ -111,6 +116,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { shortWorkspacePath } from "@/lib/workspace";
|
import { shortWorkspacePath } from "@/lib/workspace";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import type {
|
import type {
|
||||||
|
AutomationsPayload,
|
||||||
CliAppInfo,
|
CliAppInfo,
|
||||||
CliAppsPayload,
|
CliAppsPayload,
|
||||||
ImageGenerationSettingsUpdate,
|
ImageGenerationSettingsUpdate,
|
||||||
@@ -118,6 +124,7 @@ import type {
|
|||||||
McpPresetsPayload,
|
McpPresetsPayload,
|
||||||
NetworkSafetySettingsUpdate,
|
NetworkSafetySettingsUpdate,
|
||||||
ProviderModelsPayload,
|
ProviderModelsPayload,
|
||||||
|
SessionAutomationJob,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
SkillSummary,
|
SkillSummary,
|
||||||
TranscriptionSettingsUpdate,
|
TranscriptionSettingsUpdate,
|
||||||
@@ -133,6 +140,7 @@ export type SettingsSectionKey =
|
|||||||
| "voice"
|
| "voice"
|
||||||
| "browser"
|
| "browser"
|
||||||
| "apps"
|
| "apps"
|
||||||
|
| "automations"
|
||||||
| "skills"
|
| "skills"
|
||||||
| "runtime"
|
| "runtime"
|
||||||
| "advanced";
|
| "advanced";
|
||||||
@@ -140,6 +148,8 @@ export type SettingsSectionKey =
|
|||||||
type LocalDensity = "comfortable" | "compact";
|
type LocalDensity = "comfortable" | "compact";
|
||||||
type LocalActivityMode = "auto" | "expanded";
|
type LocalActivityMode = "auto" | "expanded";
|
||||||
type AppsKindFilter = "all" | "cli" | "mcp";
|
type AppsKindFilter = "all" | "cli" | "mcp";
|
||||||
|
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
|
||||||
|
type AutomationAction = "enable" | "disable" | "delete" | "run";
|
||||||
type AppsCatalogItem =
|
type AppsCatalogItem =
|
||||||
| { id: string; kind: "cli"; app: CliAppInfo }
|
| { id: string; kind: "cli"; app: CliAppInfo }
|
||||||
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
||||||
@@ -509,9 +519,11 @@ export function SettingsView({
|
|||||||
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
|
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
|
||||||
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
||||||
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
|
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
|
||||||
|
const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
|
||||||
const [loading, setLoading] = useState(() => initialSettings === null);
|
const [loading, setLoading] = useState(() => initialSettings === null);
|
||||||
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
||||||
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
|
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
|
||||||
|
const [automationsLoading, setAutomationsLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false);
|
const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false);
|
||||||
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
|
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
|
||||||
@@ -533,12 +545,18 @@ export function SettingsView({
|
|||||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||||
const [providerQuery, setProviderQuery] = useState("");
|
const [providerQuery, setProviderQuery] = useState("");
|
||||||
const [appsQuery, setAppsQuery] = useState("");
|
const [appsQuery, setAppsQuery] = useState("");
|
||||||
|
const [automationsQuery, setAutomationsQuery] = useState("");
|
||||||
|
const [automationsFilter, setAutomationsFilter] = useState<AutomationFilter>("all");
|
||||||
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
|
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
|
||||||
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
|
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
|
||||||
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
|
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
|
||||||
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all");
|
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all");
|
||||||
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
|
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
|
||||||
const [mcpError, setMcpError] = useState<string | null>(null);
|
const [mcpError, setMcpError] = useState<string | null>(null);
|
||||||
|
const [automationsError, setAutomationsError] = useState<string | null>(null);
|
||||||
|
const [automationAction, setAutomationAction] = useState<string | null>(null);
|
||||||
|
const [automationPendingDelete, setAutomationPendingDelete] =
|
||||||
|
useState<SessionAutomationJob | null>(null);
|
||||||
const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({});
|
const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({});
|
||||||
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
|
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
|
||||||
const [mcpConfigImport, setMcpConfigImport] = useState("");
|
const [mcpConfigImport, setMcpConfigImport] = useState("");
|
||||||
@@ -701,6 +719,28 @@ export function SettingsView({
|
|||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
|
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 (
|
const handleMcpPresetAction = async (
|
||||||
action: "enable" | "remove" | "test",
|
action: "enable" | "remove" | "test",
|
||||||
name: string,
|
name: string,
|
||||||
@@ -1505,6 +1575,22 @@ export function SettingsView({
|
|||||||
isRestarting={isRestarting || hostEngineApplying}
|
isRestarting={isRestarting || hostEngineApplying}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "automations":
|
||||||
|
return (
|
||||||
|
<AutomationsSettings
|
||||||
|
payload={automations}
|
||||||
|
loading={automationsLoading}
|
||||||
|
query={automationsQuery}
|
||||||
|
filter={automationsFilter}
|
||||||
|
actionKey={automationAction}
|
||||||
|
error={automationsError}
|
||||||
|
onQueryChange={setAutomationsQuery}
|
||||||
|
onFilterChange={setAutomationsFilter}
|
||||||
|
onRefresh={refreshAutomations}
|
||||||
|
onAction={handleAutomationAction}
|
||||||
|
onRequestDelete={setAutomationPendingDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "skills":
|
case "skills":
|
||||||
return <SkillsCatalogSettings skills={skills} />;
|
return <SkillsCatalogSettings skills={skills} />;
|
||||||
case "runtime":
|
case "runtime":
|
||||||
@@ -1563,6 +1649,15 @@ export function SettingsView({
|
|||||||
onSave={handleCreateModelConfiguration}
|
onSave={handleCreateModelConfiguration}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AutomationDeleteDialog
|
||||||
|
job={automationPendingDelete}
|
||||||
|
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setAutomationPendingDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={(job) => handleAutomationAction("delete", job)}
|
||||||
|
/>
|
||||||
|
|
||||||
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -3247,6 +3342,484 @@ function WebSettings({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutomationsSettings({
|
||||||
|
payload,
|
||||||
|
loading,
|
||||||
|
query,
|
||||||
|
filter,
|
||||||
|
actionKey,
|
||||||
|
error,
|
||||||
|
onQueryChange,
|
||||||
|
onFilterChange,
|
||||||
|
onRefresh,
|
||||||
|
onAction,
|
||||||
|
onRequestDelete,
|
||||||
|
}: {
|
||||||
|
payload: AutomationsPayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
query: string;
|
||||||
|
filter: AutomationFilter;
|
||||||
|
actionKey: string | null;
|
||||||
|
error: string | null;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onFilterChange: (value: AutomationFilter) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
||||||
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||||
|
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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="rounded-[24px] border border-border/50 bg-card/82 px-4 py-4 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl sm:px-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
|
||||||
|
<CalendarClock className="h-4 w-4" aria-hidden />
|
||||||
|
{tx("settings.automations.kicker", "Workspace automations")}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-2 text-[22px] font-normal leading-tight tracking-normal text-foreground">
|
||||||
|
{tx("settings.automations.title", "Automations")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-[36rem] text-[13px] leading-6 text-muted-foreground">
|
||||||
|
{tx(
|
||||||
|
"settings.automations.description",
|
||||||
|
"Review cron reminders, recurring agent turns, one-time jobs, and protected system automations in one place.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-9 rounded-full px-3 text-[13px]"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
{tx("settings.automations.refresh", "Refresh")}
|
||||||
|
</Button>
|
||||||
|
<Button asChild className="h-9 rounded-full px-3 text-[13px]">
|
||||||
|
<a href="#/new">
|
||||||
|
<Plus className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
{tx("settings.automations.newInChat", "New in chat")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-2 sm:grid-cols-4">
|
||||||
|
<AutomationStat label={tx("settings.automations.stats.active", "Active")} value={activeCount} />
|
||||||
|
<AutomationStat label={tx("settings.automations.stats.paused", "Paused")} value={pausedCount} />
|
||||||
|
<AutomationStat label={tx("settings.automations.stats.failed", "Failed")} value={failedCount} />
|
||||||
|
<AutomationStat label={tx("settings.automations.stats.system", "System")} value={systemCount} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => 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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={filter}
|
||||||
|
options={filterOptions}
|
||||||
|
onChange={(value) => onFilterChange(value as AutomationFilter)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||||
|
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SettingsSectionTitle>{tx("settings.automations.queue", "Queue")}</SettingsSectionTitle>
|
||||||
|
{loading && !payload ? (
|
||||||
|
<div className="flex h-40 items-center justify-center rounded-[22px] border border-border/45 bg-card/78 text-[13px] text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
{tx("settings.automations.loading", "Loading automations...")}
|
||||||
|
</div>
|
||||||
|
) : filtered.length ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{filtered.map((job) => (
|
||||||
|
<AutomationRow
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
locale={i18n.resolvedLanguage || i18n.language}
|
||||||
|
actionKey={actionKey}
|
||||||
|
onAction={onAction}
|
||||||
|
onRequestDelete={onRequestDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[22px] border border-border/45 bg-card/78 px-5 py-10 text-center text-[13px] text-muted-foreground">
|
||||||
|
{jobs.length
|
||||||
|
? tx("settings.automations.noMatches", "No automations match this view.")
|
||||||
|
: tx("settings.automations.empty", "No automations yet.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationStat({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[16px] border border-border/45 bg-background/65 px-3 py-2.5">
|
||||||
|
<div className="text-[11px] font-medium uppercase leading-none text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1.5 text-[20px] font-normal leading-none text-foreground">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationRow({
|
||||||
|
job,
|
||||||
|
locale,
|
||||||
|
actionKey,
|
||||||
|
onAction,
|
||||||
|
onRequestDelete,
|
||||||
|
}: {
|
||||||
|
job: SessionAutomationJob;
|
||||||
|
locale: string;
|
||||||
|
actionKey: string | null;
|
||||||
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
||||||
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||||
|
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 (
|
||||||
|
<article className="rounded-[22px] border border-border/45 bg-card/86 p-4 shadow-[0_18px_65px_rgba(15,23,42,0.06)] backdrop-blur-xl">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate text-[15px] font-medium leading-6 text-foreground">
|
||||||
|
{job.name || job.id}
|
||||||
|
</span>
|
||||||
|
<StatusPill tone={status.tone}>{status.label}</StatusPill>
|
||||||
|
{job.delete_after_run ? (
|
||||||
|
<StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 line-clamp-2 max-w-[46rem] text-[13px] leading-6 text-muted-foreground">
|
||||||
|
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-2 text-[12px] text-muted-foreground md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<AutomationDetail label={tx("settings.automations.labels.schedule", "Schedule")}>
|
||||||
|
{formatAutomationSchedule(job, locale, tx)}
|
||||||
|
</AutomationDetail>
|
||||||
|
<AutomationDetail label={tx("settings.automations.labels.next", "Next")}>
|
||||||
|
{formatAutomationNext(job, tx)}
|
||||||
|
</AutomationDetail>
|
||||||
|
<AutomationDetail label={tx("settings.automations.labels.last", "Last")}>
|
||||||
|
{formatAutomationLast(job, locale, tx)}
|
||||||
|
</AutomationDetail>
|
||||||
|
<AutomationDetail label={tx("settings.automations.labels.origin", "Origin")}>
|
||||||
|
{job.origin?.session_key ? (
|
||||||
|
<a
|
||||||
|
className="inline-flex max-w-full items-center gap-1 text-foreground/80 underline-offset-2 hover:underline"
|
||||||
|
href={`#/chat/${encodeURIComponent(job.origin.session_key)}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{origin}</span>
|
||||||
|
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
origin
|
||||||
|
)}
|
||||||
|
</AutomationDetail>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.state.last_error ? (
|
||||||
|
<div className="mt-3 rounded-[14px] bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||||
|
{job.state.last_error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{history.length ? (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{history.slice(-4).map((record) => (
|
||||||
|
<span
|
||||||
|
key={`${record.run_at_ms}:${record.status}`}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-1 text-[11px]",
|
||||||
|
record.status === "error"
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: record.status === "skipped"
|
||||||
|
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||||
|
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
)}
|
||||||
|
title={record.error || fmtDateTime(record.run_at_ms, locale)}
|
||||||
|
>
|
||||||
|
{record.status} · {formatAutomationRunDuration(record.duration_ms)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
{canManage ? (
|
||||||
|
<>
|
||||||
|
<AppsActionButton
|
||||||
|
ariaLabel={tx("settings.automations.runNow", "Run now")}
|
||||||
|
busy={actionKey === `run:${job.id}`}
|
||||||
|
disabled={!canRun}
|
||||||
|
onClick={() => void onAction("run", job)}
|
||||||
|
>
|
||||||
|
<PlayCircle className="h-4 w-4" aria-hidden />
|
||||||
|
</AppsActionButton>
|
||||||
|
<AppsActionButton
|
||||||
|
ariaLabel={
|
||||||
|
job.enabled
|
||||||
|
? tx("settings.automations.pause", "Pause")
|
||||||
|
: tx("settings.automations.resume", "Resume")
|
||||||
|
}
|
||||||
|
busy={toggleBusy}
|
||||||
|
onClick={() => void onAction(toggleAction, job)}
|
||||||
|
>
|
||||||
|
{job.enabled ? (
|
||||||
|
<PauseCircle className="h-4 w-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="h-4 w-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</AppsActionButton>
|
||||||
|
<AppsActionButton
|
||||||
|
ariaLabel={tx("settings.automations.delete", "Delete")}
|
||||||
|
tone="danger"
|
||||||
|
disabled={Boolean(actionKey)}
|
||||||
|
onClick={() => onRequestDelete(job)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" aria-hidden />
|
||||||
|
</AppsActionButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||||
|
{tx("settings.automations.protected", "Protected")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationDetail({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 rounded-[14px] bg-muted/35 px-3 py-2">
|
||||||
|
<div className="text-[10.5px] font-medium uppercase leading-none text-muted-foreground/75">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 truncate text-[12.5px] leading-5 text-foreground/85">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationDeleteDialog({
|
||||||
|
job,
|
||||||
|
deleting,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
job: SessionAutomationJob | null;
|
||||||
|
deleting: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: (job: SessionAutomationJob) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||||
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||||
|
return (
|
||||||
|
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[min(calc(100vw-2rem),26rem)] rounded-[26px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{tx(
|
||||||
|
"settings.automations.deleteDescription",
|
||||||
|
"This removes {{name}} from the cron store. Past chat messages stay in the session.",
|
||||||
|
{ name: job?.name || job?.id || "" },
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
{tx("settings.automations.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => job && void onConfirm(job)}
|
||||||
|
disabled={!job || deleting}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
|
{tx("settings.automations.delete", "Delete")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, unknown>) => 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, unknown>) => 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, unknown>) => 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, unknown>) => 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, unknown>) => 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({
|
function AppsCatalogSettings({
|
||||||
cliApps,
|
cliApps,
|
||||||
mcpPresets,
|
mcpPresets,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "Change language"
|
"ariaLabel": "Change language"
|
||||||
},
|
},
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
|
"automations": "Automations",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Skills"
|
"title": "Skills"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"runtime": "System",
|
"runtime": "System",
|
||||||
"advanced": "Security",
|
"advanced": "Security",
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
|
"automations": "Automations",
|
||||||
"skills": "Skills"
|
"skills": "Skills"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "Loading Apps...",
|
"loading": "Loading Apps...",
|
||||||
"empty": "No apps match this filter."
|
"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": {
|
"oauth": {
|
||||||
"authentication": "OAuth authentication",
|
"authentication": "OAuth authentication",
|
||||||
"signIn": "Sign in",
|
"signIn": "Sign in",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "Cambiar idioma"
|
"ariaLabel": "Cambiar idioma"
|
||||||
},
|
},
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
|
"automations": "Automatizaciones",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Habilidades"
|
"title": "Habilidades"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"cliApps": "Apps CLI",
|
"cliApps": "Apps CLI",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "Aplicaciones",
|
"apps": "Aplicaciones",
|
||||||
|
"automations": "Automatizaciones",
|
||||||
"skills": "Habilidades"
|
"skills": "Habilidades"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "Cargando apps...",
|
"loading": "Cargando apps...",
|
||||||
"empty": "Ninguna app coincide con este filtro."
|
"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": {
|
"oauth": {
|
||||||
"authentication": "Autenticación OAuth",
|
"authentication": "Autenticación OAuth",
|
||||||
"signIn": "Iniciar sesión",
|
"signIn": "Iniciar sesión",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "Changer de langue"
|
"ariaLabel": "Changer de langue"
|
||||||
},
|
},
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
|
"automations": "Automatisations",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Compétences"
|
"title": "Compétences"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"cliApps": "Apps CLI",
|
"cliApps": "Apps CLI",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "Applications",
|
"apps": "Applications",
|
||||||
|
"automations": "Automatisations",
|
||||||
"skills": "Compétences"
|
"skills": "Compétences"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "Chargement des apps...",
|
"loading": "Chargement des apps...",
|
||||||
"empty": "Aucune app ne correspond."
|
"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": {
|
"oauth": {
|
||||||
"authentication": "Authentification OAuth",
|
"authentication": "Authentification OAuth",
|
||||||
"signIn": "Se connecter",
|
"signIn": "Se connecter",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "Ganti bahasa"
|
"ariaLabel": "Ganti bahasa"
|
||||||
},
|
},
|
||||||
"apps": "Aplikasi",
|
"apps": "Aplikasi",
|
||||||
|
"automations": "Otomasi",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Skill"
|
"title": "Skill"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"cliApps": "Aplikasi CLI",
|
"cliApps": "Aplikasi CLI",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "Aplikasi",
|
"apps": "Aplikasi",
|
||||||
|
"automations": "Otomasi",
|
||||||
"skills": "Skill"
|
"skills": "Skill"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "Memuat aplikasi...",
|
"loading": "Memuat aplikasi...",
|
||||||
"empty": "Tidak ada aplikasi yang cocok."
|
"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": {
|
"oauth": {
|
||||||
"authentication": "Autentikasi OAuth",
|
"authentication": "Autentikasi OAuth",
|
||||||
"signIn": "Masuk",
|
"signIn": "Masuk",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "言語を変更"
|
"ariaLabel": "言語を変更"
|
||||||
},
|
},
|
||||||
"apps": "アプリ",
|
"apps": "アプリ",
|
||||||
|
"automations": "自動タスク",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "スキル"
|
"title": "スキル"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"cliApps": "CLI アプリ",
|
"cliApps": "CLI アプリ",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "アプリ",
|
"apps": "アプリ",
|
||||||
|
"automations": "自動タスク",
|
||||||
"skills": "スキル"
|
"skills": "スキル"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "アプリを読み込み中...",
|
"loading": "アプリを読み込み中...",
|
||||||
"empty": "一致するアプリはありません。"
|
"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": {
|
"oauth": {
|
||||||
"authentication": "OAuth 認証",
|
"authentication": "OAuth 認証",
|
||||||
"signIn": "サインイン",
|
"signIn": "サインイン",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "언어 변경"
|
"ariaLabel": "언어 변경"
|
||||||
},
|
},
|
||||||
"apps": "앱",
|
"apps": "앱",
|
||||||
|
"automations": "자동화",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "스킬"
|
"title": "스킬"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"cliApps": "CLI 앱",
|
"cliApps": "CLI 앱",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "앱",
|
"apps": "앱",
|
||||||
|
"automations": "자동화",
|
||||||
"skills": "스킬"
|
"skills": "스킬"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "앱을 불러오는 중...",
|
"loading": "앱을 불러오는 중...",
|
||||||
"empty": "일치하는 앱이 없습니다."
|
"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": {
|
"oauth": {
|
||||||
"authentication": "OAuth 인증",
|
"authentication": "OAuth 인증",
|
||||||
"signIn": "로그인",
|
"signIn": "로그인",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "Đổi ngôn ngữ"
|
"ariaLabel": "Đổi ngôn ngữ"
|
||||||
},
|
},
|
||||||
"apps": "Ứng dụng",
|
"apps": "Ứng dụng",
|
||||||
|
"automations": "Tự động hóa",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Kỹ năng"
|
"title": "Kỹ năng"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"cliApps": "Ứng dụng CLI",
|
"cliApps": "Ứng dụng CLI",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "Ứng dụng",
|
"apps": "Ứng dụng",
|
||||||
|
"automations": "Tự động hóa",
|
||||||
"skills": "Kỹ năng"
|
"skills": "Kỹ năng"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "Đang tải ứng dụng...",
|
"loading": "Đang tải ứng dụng...",
|
||||||
"empty": "Không có ứng dụng phù hợp."
|
"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": {
|
"oauth": {
|
||||||
"authentication": "Xác thực OAuth",
|
"authentication": "Xác thực OAuth",
|
||||||
"signIn": "Đăng nhập",
|
"signIn": "Đăng nhập",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "切换语言"
|
"ariaLabel": "切换语言"
|
||||||
},
|
},
|
||||||
"apps": "应用",
|
"apps": "应用",
|
||||||
|
"automations": "自动任务",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "技能"
|
"title": "技能"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"runtime": "系统",
|
"runtime": "系统",
|
||||||
"advanced": "安全",
|
"advanced": "安全",
|
||||||
"apps": "应用",
|
"apps": "应用",
|
||||||
|
"automations": "自动任务",
|
||||||
"skills": "技能"
|
"skills": "技能"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "正在加载应用...",
|
"loading": "正在加载应用...",
|
||||||
"empty": "没有匹配的应用。"
|
"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": {
|
"oauth": {
|
||||||
"authentication": "OAuth 认证",
|
"authentication": "OAuth 认证",
|
||||||
"signIn": "登录",
|
"signIn": "登录",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ariaLabel": "切換語言"
|
"ariaLabel": "切換語言"
|
||||||
},
|
},
|
||||||
"apps": "應用",
|
"apps": "應用",
|
||||||
|
"automations": "自動任務",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "技能"
|
"title": "技能"
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"cliApps": "CLI 應用",
|
"cliApps": "CLI 應用",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "應用",
|
"apps": "應用",
|
||||||
|
"automations": "自動任務",
|
||||||
"skills": "技能"
|
"skills": "技能"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -461,6 +463,74 @@
|
|||||||
"loading": "正在載入應用...",
|
"loading": "正在載入應用...",
|
||||||
"empty": "沒有符合的應用。"
|
"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": {
|
"oauth": {
|
||||||
"authentication": "OAuth 認證",
|
"authentication": "OAuth 認證",
|
||||||
"signIn": "登入",
|
"signIn": "登入",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AutomationsPayload,
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
CliAppsPayload,
|
CliAppsPayload,
|
||||||
FilePreviewPayload,
|
FilePreviewPayload,
|
||||||
@@ -184,6 +185,34 @@ export async function fetchSessionAutomations(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAutomations(
|
||||||
|
token: string,
|
||||||
|
base: string = "",
|
||||||
|
): Promise<AutomationsPayload> {
|
||||||
|
return request<AutomationsPayload>(
|
||||||
|
`${base}/api/webui/automations`,
|
||||||
|
token,
|
||||||
|
undefined,
|
||||||
|
API_READ_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAutomationAction(
|
||||||
|
token: string,
|
||||||
|
action: "enable" | "disable" | "delete" | "run",
|
||||||
|
id: string,
|
||||||
|
base: string = "",
|
||||||
|
): Promise<AutomationsPayload> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("id", id);
|
||||||
|
return request<AutomationsPayload>(
|
||||||
|
`${base}/api/webui/automations/${action}?${query}`,
|
||||||
|
token,
|
||||||
|
undefined,
|
||||||
|
API_READ_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSkills(
|
export async function fetchSkills(
|
||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export interface SessionAutomationJob {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
protected?: boolean;
|
||||||
|
delete_after_run?: boolean;
|
||||||
|
created_at_ms?: number | null;
|
||||||
|
updated_at_ms?: number | null;
|
||||||
schedule: {
|
schedule: {
|
||||||
kind: "at" | "every" | "cron" | string;
|
kind: "at" | "every" | "cron" | string;
|
||||||
at_ms?: number | null;
|
at_ms?: number | null;
|
||||||
@@ -109,15 +113,35 @@ export interface SessionAutomationJob {
|
|||||||
};
|
};
|
||||||
payload: {
|
payload: {
|
||||||
message: string;
|
message: string;
|
||||||
|
kind?: "agent_turn" | "system_event" | string;
|
||||||
|
session_key?: string | null;
|
||||||
|
origin_channel?: string | null;
|
||||||
|
origin_chat_id?: string | null;
|
||||||
};
|
};
|
||||||
state: {
|
state: {
|
||||||
next_run_at_ms?: number | null;
|
next_run_at_ms?: number | null;
|
||||||
|
last_run_at_ms?: number | null;
|
||||||
last_status?: "ok" | "error" | "skipped" | string | null;
|
last_status?: "ok" | "error" | "skipped" | string | null;
|
||||||
|
last_error?: string | null;
|
||||||
pending?: boolean;
|
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 SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
|
||||||
|
export interface AutomationsPayload { jobs: SessionAutomationJob[]; }
|
||||||
|
|
||||||
export interface SessionDeleteResult {
|
export interface SessionDeleteResult {
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createModelConfiguration,
|
createModelConfiguration,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
fetchFilePreview,
|
fetchFilePreview,
|
||||||
|
fetchAutomations,
|
||||||
fetchCliApps,
|
fetchCliApps,
|
||||||
fetchInstalledCliApps,
|
fetchInstalledCliApps,
|
||||||
fetchMcpPresets,
|
fetchMcpPresets,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
listSlashCommands,
|
listSlashCommands,
|
||||||
loginProviderOAuth,
|
loginProviderOAuth,
|
||||||
logoutProviderOAuth,
|
logoutProviderOAuth,
|
||||||
|
runAutomationAction,
|
||||||
runCliAppAction,
|
runCliAppAction,
|
||||||
runMcpPresetAction,
|
runMcpPresetAction,
|
||||||
saveCustomMcpServer,
|
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 () => {
|
it("fetches the WebUI skill summary", async () => {
|
||||||
await fetchSkills("tok");
|
await fetchSkills("tok");
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,96 @@ describe("App layout", () => {
|
|||||||
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
|
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(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
const automationsButton = within(sidebar).getByRole("button", {
|
||||||
|
name: "Automations",
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(automationsButton);
|
||||||
|
|
||||||
|
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(<App />);
|
||||||
|
|
||||||
|
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 () => {
|
it("fully collapses the native host sidebar and previews it on hover", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const SETTINGS_NAV_KEYS = [
|
|||||||
"image",
|
"image",
|
||||||
"browser",
|
"browser",
|
||||||
"apps",
|
"apps",
|
||||||
|
"automations",
|
||||||
"runtime",
|
"runtime",
|
||||||
"advanced",
|
"advanced",
|
||||||
];
|
];
|
||||||
@@ -43,8 +44,21 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
|||||||
"settings.nav.models",
|
"settings.nav.models",
|
||||||
"settings.nav.providers",
|
"settings.nav.providers",
|
||||||
"settings.nav.apps",
|
"settings.nav.apps",
|
||||||
|
"settings.nav.automations",
|
||||||
"settings.nav.runtime",
|
"settings.nav.runtime",
|
||||||
"settings.nav.advanced",
|
"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.interface",
|
||||||
"settings.sections.localPreferences",
|
"settings.sections.localPreferences",
|
||||||
"settings.sections.webSearch",
|
"settings.sections.webSearch",
|
||||||
|
|||||||
Reference in New Issue
Block a user