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
|
||||
|
||||
@@ -9,6 +9,8 @@ from nanobot.cron.types import CronJob
|
||||
|
||||
|
||||
class _CronServiceLike(Protocol):
|
||||
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: ...
|
||||
|
||||
def list_bound_cron_jobs_for_session(
|
||||
self,
|
||||
session_key: str,
|
||||
@@ -17,6 +19,10 @@ class _CronServiceLike(Protocol):
|
||||
) -> list[CronJob]: ...
|
||||
|
||||
|
||||
class _SessionManagerLike(Protocol):
|
||||
def read_session_file(self, key: str) -> dict[str, Any] | None: ...
|
||||
|
||||
|
||||
def session_automation_jobs(
|
||||
cron_service: _CronServiceLike | None,
|
||||
session_key: str,
|
||||
@@ -45,16 +51,50 @@ def session_automations_payload(
|
||||
}
|
||||
|
||||
|
||||
def all_automations_payload(
|
||||
cron_service: _CronServiceLike | None,
|
||||
*,
|
||||
session_manager: _SessionManagerLike | None = None,
|
||||
pending_job_ids: Collection[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return all cron jobs visible to the WebUI automation manager."""
|
||||
jobs = cron_service.list_jobs(include_disabled=True) if cron_service is not None else []
|
||||
return {
|
||||
"jobs": serialize_automation_jobs(
|
||||
jobs,
|
||||
pending_job_ids=pending_job_ids,
|
||||
include_details=True,
|
||||
session_manager=session_manager,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def serialize_automation_jobs(
|
||||
jobs: list[CronJob],
|
||||
*,
|
||||
pending_job_ids: Collection[str] | None = None,
|
||||
include_details: bool = False,
|
||||
session_manager: _SessionManagerLike | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
return [_serialize_job(job, pending=job.id in (pending_job_ids or ())) for job in jobs]
|
||||
return [
|
||||
_serialize_job(
|
||||
job,
|
||||
pending=job.id in (pending_job_ids or ()),
|
||||
include_details=include_details,
|
||||
session_manager=session_manager,
|
||||
)
|
||||
for job in jobs
|
||||
]
|
||||
|
||||
|
||||
def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
def _serialize_job(
|
||||
job: CronJob,
|
||||
*,
|
||||
pending: bool = False,
|
||||
include_details: bool = False,
|
||||
session_manager: _SessionManagerLike | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"enabled": job.enabled,
|
||||
@@ -74,3 +114,75 @@ def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
|
||||
"pending": pending,
|
||||
},
|
||||
}
|
||||
if not include_details:
|
||||
return payload
|
||||
|
||||
payload["protected"] = job.payload.kind == "system_event"
|
||||
payload["delete_after_run"] = job.delete_after_run
|
||||
payload["created_at_ms"] = job.created_at_ms
|
||||
payload["updated_at_ms"] = job.updated_at_ms
|
||||
payload["payload"].update(
|
||||
{
|
||||
"kind": job.payload.kind,
|
||||
"session_key": job.payload.session_key,
|
||||
"origin_channel": job.payload.origin_channel,
|
||||
"origin_chat_id": job.payload.origin_chat_id,
|
||||
}
|
||||
)
|
||||
payload["state"].update(
|
||||
{
|
||||
"last_run_at_ms": job.state.last_run_at_ms,
|
||||
"last_error": job.state.last_error,
|
||||
"run_history": [
|
||||
{
|
||||
"run_at_ms": record.run_at_ms,
|
||||
"status": record.status,
|
||||
"duration_ms": record.duration_ms,
|
||||
"error": record.error,
|
||||
}
|
||||
for record in job.state.run_history[-5:]
|
||||
],
|
||||
}
|
||||
)
|
||||
payload["origin"] = _origin_payload(job, session_manager)
|
||||
return payload
|
||||
|
||||
|
||||
def _origin_payload(
|
||||
job: CronJob,
|
||||
session_manager: _SessionManagerLike | None,
|
||||
) -> dict[str, Any] | None:
|
||||
session_key = job.payload.session_key
|
||||
if not session_key and job.payload.origin_channel and job.payload.origin_chat_id:
|
||||
session_key = f"{job.payload.origin_channel}:{job.payload.origin_chat_id}"
|
||||
if not session_key:
|
||||
return None
|
||||
|
||||
title = ""
|
||||
preview = ""
|
||||
if session_manager is not None:
|
||||
data = session_manager.read_session_file(session_key)
|
||||
if isinstance(data, dict):
|
||||
title = str(data.get("title") or "")
|
||||
preview = _session_preview(data.get("messages"))
|
||||
|
||||
channel, _, chat_id = session_key.partition(":")
|
||||
return {
|
||||
"session_key": session_key,
|
||||
"channel": channel,
|
||||
"chat_id": chat_id,
|
||||
"title": title,
|
||||
"preview": preview,
|
||||
}
|
||||
|
||||
|
||||
def _session_preview(messages: Any) -> str:
|
||||
if not isinstance(messages, list):
|
||||
return ""
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
content = message.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content.strip()
|
||||
return ""
|
||||
|
||||
@@ -64,6 +64,7 @@ from nanobot.webui.http_utils import (
|
||||
)
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.session_automations import (
|
||||
all_automations_payload,
|
||||
serialize_automation_jobs,
|
||||
session_automation_jobs,
|
||||
session_automations_payload,
|
||||
@@ -236,6 +237,11 @@ class GatewayHTTPHandler:
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
# Automation routes
|
||||
response = await self._dispatch_automation_routes(request, got)
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
# Misc routes
|
||||
response = await self._dispatch_misc_routes(connection, request, got)
|
||||
if response is not None:
|
||||
@@ -514,6 +520,95 @@ class GatewayHTTPHandler:
|
||||
delete_webui_thread(decoded_key)
|
||||
return _http_json_response({"deleted": bool(deleted)})
|
||||
|
||||
# -- Automation routes --------------------------------------------------
|
||||
|
||||
async def _dispatch_automation_routes(
|
||||
self,
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
) -> Response | None:
|
||||
if got == "/api/webui/automations":
|
||||
return self._handle_webui_automations(request)
|
||||
m = re.match(r"^/api/webui/automations/(enable|disable|delete|run)$", got)
|
||||
if m:
|
||||
return await self._handle_webui_automation_action(request, m.group(1))
|
||||
return None
|
||||
|
||||
def _pending_cron_job_ids_for_all(self) -> set[str]:
|
||||
if self.cron_service is None or self.cron_pending_job_ids is None:
|
||||
return set()
|
||||
pending: set[str] = set()
|
||||
for job in self.cron_service.list_jobs(include_disabled=True):
|
||||
session_key = job.payload.session_key
|
||||
if not session_key and job.payload.origin_channel and job.payload.origin_chat_id:
|
||||
session_key = f"{job.payload.origin_channel}:{job.payload.origin_chat_id}"
|
||||
if session_key:
|
||||
pending.update(self.cron_pending_job_ids(session_key))
|
||||
return pending
|
||||
|
||||
def _handle_webui_automations(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
return _http_json_response(
|
||||
all_automations_payload(
|
||||
self.cron_service,
|
||||
session_manager=self.session_manager,
|
||||
pending_job_ids=self._pending_cron_job_ids_for_all(),
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_webui_automation_action(
|
||||
self,
|
||||
request: WsRequest,
|
||||
action: str,
|
||||
) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if self.cron_service is None:
|
||||
return _http_error(503, "cron service unavailable")
|
||||
|
||||
query = _parse_query(request.path)
|
||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||
if not job_id:
|
||||
return _http_error(400, "missing automation id")
|
||||
job = self.cron_service.get_job(job_id)
|
||||
if job is None:
|
||||
return _http_error(404, "automation not found")
|
||||
if job.payload.kind == "system_event":
|
||||
return _http_error(403, "system automation is protected")
|
||||
|
||||
if action == "enable":
|
||||
if self.cron_service.enable_job(job_id, enabled=True) is None:
|
||||
return _http_error(404, "automation not found")
|
||||
elif action == "disable":
|
||||
if self.cron_service.enable_job(job_id, enabled=False) is None:
|
||||
return _http_error(404, "automation not found")
|
||||
elif action == "delete":
|
||||
result = self.cron_service.remove_job(job_id)
|
||||
if result == "not_found":
|
||||
return _http_error(404, "automation not found")
|
||||
if result == "protected":
|
||||
return _http_error(403, "system automation is protected")
|
||||
elif action == "run":
|
||||
if not job.enabled:
|
||||
return _http_error(409, "automation is disabled")
|
||||
task = asyncio.create_task(self.cron_service.run_job(job_id, force=False))
|
||||
task.add_done_callback(self._log_automation_run_result)
|
||||
else:
|
||||
return _http_error(404, "unknown automation action")
|
||||
|
||||
return self._handle_webui_automations(request)
|
||||
|
||||
@staticmethod
|
||||
def _log_automation_run_result(task: asyncio.Task[bool]) -> None:
|
||||
try:
|
||||
ran = task.result()
|
||||
except Exception:
|
||||
logger.exception("WebUI automation run-now task failed")
|
||||
return
|
||||
if not ran:
|
||||
logger.warning("WebUI automation run-now task did not execute")
|
||||
|
||||
# -- Media routes -------------------------------------------------------
|
||||
|
||||
def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||
|
||||
@@ -813,6 +813,91 @@ async def test_session_delete_removes_file(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
user_job = cron.add_job(
|
||||
name="Daily repo check",
|
||||
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
||||
message="Check the repo status",
|
||||
session_key="websocket:abc",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="abc",
|
||||
)
|
||||
cron.register_system_job(
|
||||
CronJob(
|
||||
id="heartbeat",
|
||||
name="heartbeat",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
)
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
||||
cron_service=cron,
|
||||
cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(),
|
||||
port=29932,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29932/api/webui/automations")
|
||||
assert deny.status_code == 401
|
||||
|
||||
boot = await _http_get("http://127.0.0.1:29932/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29932/api/webui/automations",
|
||||
headers=auth,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
by_id = {job["id"]: job for job in body["jobs"]}
|
||||
assert by_id[user_job.id]["protected"] is False
|
||||
assert by_id[user_job.id]["state"]["pending"] is True
|
||||
assert by_id[user_job.id]["state"]["run_history"] == []
|
||||
assert by_id[user_job.id]["origin"]["session_key"] == "websocket:abc"
|
||||
assert by_id[user_job.id]["origin"]["preview"] == "hi"
|
||||
assert by_id["heartbeat"]["protected"] is True
|
||||
|
||||
disabled = await _http_get(
|
||||
f"http://127.0.0.1:29932/api/webui/automations/disable?id={user_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert disabled.status_code == 200
|
||||
by_id = {job["id"]: job for job in disabled.json()["jobs"]}
|
||||
assert by_id[user_job.id]["enabled"] is False
|
||||
|
||||
protected_delete = await _http_get(
|
||||
"http://127.0.0.1:29932/api/webui/automations/delete?id=heartbeat",
|
||||
headers=auth,
|
||||
)
|
||||
assert protected_delete.status_code == 403
|
||||
|
||||
enabled = await _http_get(
|
||||
f"http://127.0.0.1:29932/api/webui/automations/enable?id={user_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert enabled.status_code == 200
|
||||
by_id = {job["id"]: job for job in enabled.json()["jobs"]}
|
||||
assert by_id[user_job.id]["enabled"] is True
|
||||
|
||||
deleted = await _http_get(
|
||||
f"http://127.0.0.1:29932/api/webui/automations/delete?id={user_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert user_job.id not in {job["id"] for job in deleted.json()["jobs"]}
|
||||
assert "heartbeat" in {job["id"] for job in deleted.json()["jobs"]}
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_blocks_when_bound_automation_exists(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
+20
-3
@@ -71,7 +71,7 @@ const SIDEBAR_WIDTH = 272;
|
||||
const SIDEBAR_RAIL_WIDTH = 56;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
type ShellView = "chat" | "settings" | "apps" | "skills";
|
||||
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
|
||||
type ShellRoute = {
|
||||
view: ShellView;
|
||||
activeKey: string | null;
|
||||
@@ -86,6 +86,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
|
||||
"voice",
|
||||
"browser",
|
||||
"apps",
|
||||
"automations",
|
||||
"skills",
|
||||
"runtime",
|
||||
"advanced",
|
||||
@@ -100,7 +101,7 @@ function defaultShellRoute(): ShellRoute {
|
||||
}
|
||||
|
||||
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
|
||||
if (section === "apps" || section === "skills") return section;
|
||||
if (section === "apps" || section === "automations" || section === "skills") return section;
|
||||
return "settings";
|
||||
}
|
||||
|
||||
@@ -129,6 +130,9 @@ function readShellRoute(): ShellRoute {
|
||||
if (path === "/apps") {
|
||||
return { view: "apps", activeKey, settingsSection: "apps" };
|
||||
}
|
||||
if (path === "/automations") {
|
||||
return { view: "automations", activeKey, settingsSection: "automations" };
|
||||
}
|
||||
if (path === "/skills") {
|
||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||
}
|
||||
@@ -1165,6 +1169,12 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
const onOpenAutomations = useCallback(() => {
|
||||
setSessionSearchOpen(false);
|
||||
navigate({ view: "automations", activeKey, settingsSection: "automations" });
|
||||
setMobileSidebarOpen(false);
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
const onOpenSkills = useCallback(() => {
|
||||
setSessionSearchOpen(false);
|
||||
navigate({ view: "skills", activeKey, settingsSection: "skills" });
|
||||
@@ -1340,6 +1350,12 @@ function Shell({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (view === "automations") {
|
||||
document.title = t("app.documentTitle.chat", {
|
||||
title: t("settings.nav.automations", { defaultValue: "Automations" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (view === "skills") {
|
||||
document.title = t("app.documentTitle.chat", {
|
||||
title: t("settings.nav.skills", { defaultValue: "Skills" }),
|
||||
@@ -1366,9 +1382,10 @@ function Shell({
|
||||
onNewChatInProject,
|
||||
onOpenSettings,
|
||||
onOpenApps,
|
||||
onOpenAutomations,
|
||||
onOpenSkills,
|
||||
onOpenSearch: onOpenSessionSearch,
|
||||
activeUtility: view === "apps" || view === "skills" ? view : null,
|
||||
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
||||
onToggleArchived,
|
||||
pinnedKeys: sidebarState.pinned_keys,
|
||||
archivedKeys: sidebarState.archived_keys,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Archive,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
Menu,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -36,8 +37,9 @@ interface SidebarProps {
|
||||
onOpenSettings: () => void;
|
||||
onOpenApps: () => void;
|
||||
onOpenSkills: () => void;
|
||||
onOpenAutomations: () => void;
|
||||
onOpenSearch: () => void;
|
||||
activeUtility?: "apps" | "skills" | null;
|
||||
activeUtility?: "apps" | "skills" | "automations" | null;
|
||||
onToggleArchived: () => void;
|
||||
onCollapse: () => void;
|
||||
onExpand?: () => void;
|
||||
@@ -159,6 +161,13 @@ export function Sidebar(props: SidebarProps) {
|
||||
active={props.activeUtility === "apps"}
|
||||
icon={<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
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.skills.title")}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ArrowUpCircle,
|
||||
Bot,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
Check,
|
||||
CircleAlert,
|
||||
ChevronDown,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
LogOut,
|
||||
Mic,
|
||||
Moon,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Orbit,
|
||||
@@ -79,6 +81,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
checkVersion,
|
||||
createModelConfiguration,
|
||||
fetchAutomations,
|
||||
fetchSettings,
|
||||
fetchSettingsUsage,
|
||||
fetchCliApps,
|
||||
@@ -87,6 +90,7 @@ import {
|
||||
importMcpConfig,
|
||||
loginProviderOAuth,
|
||||
logoutProviderOAuth,
|
||||
runAutomationAction,
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
@@ -102,6 +106,7 @@ import {
|
||||
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
||||
import { getHostApi } from "@/lib/runtime";
|
||||
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
||||
import { fmtDateTime, relativeTime } from "@/lib/format";
|
||||
import {
|
||||
logoFallbackUrls,
|
||||
providerBrand,
|
||||
@@ -111,6 +116,7 @@ import { cn } from "@/lib/utils";
|
||||
import { shortWorkspacePath } from "@/lib/workspace";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
AutomationsPayload,
|
||||
CliAppInfo,
|
||||
CliAppsPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
@@ -118,6 +124,7 @@ import type {
|
||||
McpPresetsPayload,
|
||||
NetworkSafetySettingsUpdate,
|
||||
ProviderModelsPayload,
|
||||
SessionAutomationJob,
|
||||
SettingsPayload,
|
||||
SkillSummary,
|
||||
TranscriptionSettingsUpdate,
|
||||
@@ -133,6 +140,7 @@ export type SettingsSectionKey =
|
||||
| "voice"
|
||||
| "browser"
|
||||
| "apps"
|
||||
| "automations"
|
||||
| "skills"
|
||||
| "runtime"
|
||||
| "advanced";
|
||||
@@ -140,6 +148,8 @@ export type SettingsSectionKey =
|
||||
type LocalDensity = "comfortable" | "compact";
|
||||
type LocalActivityMode = "auto" | "expanded";
|
||||
type AppsKindFilter = "all" | "cli" | "mcp";
|
||||
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
|
||||
type AutomationAction = "enable" | "disable" | "delete" | "run";
|
||||
type AppsCatalogItem =
|
||||
| { id: string; kind: "cli"; app: CliAppInfo }
|
||||
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
||||
@@ -509,9 +519,11 @@ export function SettingsView({
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
|
||||
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
||||
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
|
||||
const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(() => initialSettings === null);
|
||||
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
||||
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
|
||||
const [automationsLoading, setAutomationsLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false);
|
||||
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
|
||||
@@ -533,12 +545,18 @@ export function SettingsView({
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [providerQuery, setProviderQuery] = useState("");
|
||||
const [appsQuery, setAppsQuery] = useState("");
|
||||
const [automationsQuery, setAutomationsQuery] = useState("");
|
||||
const [automationsFilter, setAutomationsFilter] = useState<AutomationFilter>("all");
|
||||
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
|
||||
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
|
||||
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
|
||||
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all");
|
||||
const [mcpMessage, setMcpMessage] = 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 [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
|
||||
const [mcpConfigImport, setMcpConfigImport] = useState("");
|
||||
@@ -701,6 +719,28 @@ export function SettingsView({
|
||||
};
|
||||
}, [activeSection, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "automations") return;
|
||||
let cancelled = false;
|
||||
setAutomationsLoading(true);
|
||||
fetchAutomations(token)
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
setAutomations(payload);
|
||||
setAutomationsError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setAutomationsError((err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setAutomationsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSection, token]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
|
||||
@@ -1225,6 +1265,36 @@ export function SettingsView({
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAutomations = async () => {
|
||||
setAutomationsLoading(true);
|
||||
setAutomationsError(null);
|
||||
try {
|
||||
setAutomations(await fetchAutomations(token));
|
||||
} catch (err) {
|
||||
setAutomationsError((err as Error).message);
|
||||
} finally {
|
||||
setAutomationsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutomationAction = async (
|
||||
action: AutomationAction,
|
||||
job: SessionAutomationJob,
|
||||
) => {
|
||||
const key = `${action}:${job.id}`;
|
||||
setAutomationAction(key);
|
||||
setAutomationsError(null);
|
||||
try {
|
||||
const payload = await runAutomationAction(token, action, job.id);
|
||||
setAutomations(payload);
|
||||
if (action === "delete") setAutomationPendingDelete(null);
|
||||
} catch (err) {
|
||||
setAutomationsError((err as Error).message);
|
||||
} finally {
|
||||
setAutomationAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMcpPresetAction = async (
|
||||
action: "enable" | "remove" | "test",
|
||||
name: string,
|
||||
@@ -1505,6 +1575,22 @@ export function SettingsView({
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
/>
|
||||
);
|
||||
case "automations":
|
||||
return (
|
||||
<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":
|
||||
return <SkillsCatalogSettings skills={skills} />;
|
||||
case "runtime":
|
||||
@@ -1563,6 +1649,15 @@ export function SettingsView({
|
||||
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]">
|
||||
<div
|
||||
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({
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Change language"
|
||||
},
|
||||
"apps": "Apps",
|
||||
"automations": "Automations",
|
||||
"skills": {
|
||||
"title": "Skills"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"runtime": "System",
|
||||
"advanced": "Security",
|
||||
"apps": "Apps",
|
||||
"automations": "Automations",
|
||||
"skills": "Skills"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "Loading Apps...",
|
||||
"empty": "No apps match this filter."
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "Workspace automations",
|
||||
"title": "Automations",
|
||||
"description": "Review cron reminders, recurring agent turns, one-time jobs, and protected system automations in one place.",
|
||||
"refresh": "Refresh",
|
||||
"newInChat": "New in chat",
|
||||
"stats": {
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"failed": "Failed",
|
||||
"system": "System"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"failed": "Failed",
|
||||
"system": "System"
|
||||
},
|
||||
"search": "Search automation, message, session, or cron expression",
|
||||
"queue": "Queue",
|
||||
"loading": "Loading automations...",
|
||||
"noMatches": "No automations match this view.",
|
||||
"empty": "No automations yet.",
|
||||
"oneShot": "One-time",
|
||||
"systemTask": "System-managed automation",
|
||||
"labels": {
|
||||
"schedule": "Schedule",
|
||||
"next": "Next",
|
||||
"last": "Last",
|
||||
"origin": "Origin"
|
||||
},
|
||||
"runNow": "Run now",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"delete": "Delete",
|
||||
"protected": "Protected",
|
||||
"deleteTitle": "Delete automation",
|
||||
"deleteDescription": "This removes {{name}} from the cron store. Past chat messages stay in the session.",
|
||||
"cancel": "Cancel",
|
||||
"status": {
|
||||
"system": "System",
|
||||
"pending": "Pending",
|
||||
"paused": "Paused",
|
||||
"failed": "Failed",
|
||||
"active": "Active"
|
||||
},
|
||||
"origin": {
|
||||
"system": "System",
|
||||
"unknown": "Unknown session"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "At {{time}}",
|
||||
"every": "Every {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "Custom schedule"
|
||||
},
|
||||
"next": {
|
||||
"paused": "Paused",
|
||||
"pending": "Running soon",
|
||||
"none": "No next run"
|
||||
},
|
||||
"last": {
|
||||
"never": "Never",
|
||||
"unknown": "unknown"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth authentication",
|
||||
"signIn": "Sign in",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Cambiar idioma"
|
||||
},
|
||||
"apps": "Apps",
|
||||
"automations": "Automatizaciones",
|
||||
"skills": {
|
||||
"title": "Habilidades"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Aplicaciones",
|
||||
"automations": "Automatizaciones",
|
||||
"skills": "Habilidades"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "Cargando apps...",
|
||||
"empty": "Ninguna app coincide con este filtro."
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "Automatizaciones del espacio",
|
||||
"title": "Automatizaciones",
|
||||
"description": "Revisa recordatorios cron, turnos recurrentes del agente, automatizaciones de una vez y automatizaciones protegidas del sistema en un solo lugar.",
|
||||
"refresh": "Actualizar",
|
||||
"newInChat": "Crear en chat",
|
||||
"stats": {
|
||||
"active": "Activas",
|
||||
"paused": "Pausadas",
|
||||
"failed": "Fallidas",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Todas",
|
||||
"active": "Activas",
|
||||
"paused": "Pausadas",
|
||||
"failed": "Fallidas",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"search": "Buscar tarea, mensaje, sesión o expresión cron",
|
||||
"queue": "Cola",
|
||||
"loading": "Cargando automatizaciones...",
|
||||
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
||||
"empty": "Aún no hay automatizaciones.",
|
||||
"oneShot": "Una vez",
|
||||
"systemTask": "Automatización administrada por el sistema",
|
||||
"labels": {
|
||||
"schedule": "Programación",
|
||||
"next": "Siguiente",
|
||||
"last": "Última",
|
||||
"origin": "Origen"
|
||||
},
|
||||
"runNow": "Ejecutar ahora",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar",
|
||||
"delete": "Eliminar",
|
||||
"protected": "Protegida",
|
||||
"deleteTitle": "Eliminar automatización",
|
||||
"deleteDescription": "Esto elimina {{name}} del almacén cron. Los mensajes de chat anteriores permanecen en la sesión.",
|
||||
"cancel": "Cancelar",
|
||||
"status": {
|
||||
"system": "Sistema",
|
||||
"pending": "Pendiente",
|
||||
"paused": "Pausada",
|
||||
"failed": "Fallida",
|
||||
"active": "Activa"
|
||||
},
|
||||
"origin": {
|
||||
"system": "Sistema",
|
||||
"unknown": "Sesión desconocida"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "A las {{time}}",
|
||||
"every": "Cada {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "Programación personalizada"
|
||||
},
|
||||
"next": {
|
||||
"paused": "Pausada",
|
||||
"pending": "Se ejecutará pronto",
|
||||
"none": "Sin próxima ejecución"
|
||||
},
|
||||
"last": {
|
||||
"never": "Nunca",
|
||||
"unknown": "desconocido"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "Autenticación OAuth",
|
||||
"signIn": "Iniciar sesión",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Changer de langue"
|
||||
},
|
||||
"apps": "Apps",
|
||||
"automations": "Automatisations",
|
||||
"skills": {
|
||||
"title": "Compétences"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Applications",
|
||||
"automations": "Automatisations",
|
||||
"skills": "Compétences"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "Chargement des apps...",
|
||||
"empty": "Aucune app ne correspond."
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "Automatisations de l’espace",
|
||||
"title": "Automatisations",
|
||||
"description": "Passez en revue les rappels cron, tours récurrents de l’agent, automatisations ponctuelles et automatisations système protégées au même endroit.",
|
||||
"refresh": "Actualiser",
|
||||
"newInChat": "Créer dans le chat",
|
||||
"stats": {
|
||||
"active": "Actives",
|
||||
"paused": "En pause",
|
||||
"failed": "Échouées",
|
||||
"system": "Système"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Toutes",
|
||||
"active": "Actives",
|
||||
"paused": "En pause",
|
||||
"failed": "Échouées",
|
||||
"system": "Système"
|
||||
},
|
||||
"search": "Rechercher une tâche, un message, une session ou une expression cron",
|
||||
"queue": "File",
|
||||
"loading": "Chargement des automatisations...",
|
||||
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
||||
"empty": "Aucune automatisation pour le moment.",
|
||||
"oneShot": "Ponctuelle",
|
||||
"systemTask": "Automatisation gérée par le système",
|
||||
"labels": {
|
||||
"schedule": "Planning",
|
||||
"next": "Prochaine",
|
||||
"last": "Dernière",
|
||||
"origin": "Origine"
|
||||
},
|
||||
"runNow": "Exécuter maintenant",
|
||||
"pause": "Mettre en pause",
|
||||
"resume": "Reprendre",
|
||||
"delete": "Supprimer",
|
||||
"protected": "Protégée",
|
||||
"deleteTitle": "Supprimer l’automatisation",
|
||||
"deleteDescription": "Cela supprime {{name}} du stockage cron. Les anciens messages de chat restent dans la session.",
|
||||
"cancel": "Annuler",
|
||||
"status": {
|
||||
"system": "Système",
|
||||
"pending": "En attente",
|
||||
"paused": "En pause",
|
||||
"failed": "Échouée",
|
||||
"active": "En cours"
|
||||
},
|
||||
"origin": {
|
||||
"system": "Système",
|
||||
"unknown": "Session inconnue"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "À {{time}}",
|
||||
"every": "Toutes les {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "Planning personnalisé"
|
||||
},
|
||||
"next": {
|
||||
"paused": "En pause",
|
||||
"pending": "Exécution prochaine",
|
||||
"none": "Aucune prochaine exécution"
|
||||
},
|
||||
"last": {
|
||||
"never": "Jamais",
|
||||
"unknown": "inconnu"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "Authentification OAuth",
|
||||
"signIn": "Se connecter",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Ganti bahasa"
|
||||
},
|
||||
"apps": "Aplikasi",
|
||||
"automations": "Otomasi",
|
||||
"skills": {
|
||||
"title": "Skill"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "Aplikasi CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Aplikasi",
|
||||
"automations": "Otomasi",
|
||||
"skills": "Skill"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada aplikasi yang cocok."
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "Otomasi ruang kerja",
|
||||
"title": "Otomasi",
|
||||
"description": "Tinjau pengingat cron, giliran agen berulang, otomasi sekali jalan, dan otomasi sistem terlindungi di satu tempat.",
|
||||
"refresh": "Segarkan",
|
||||
"newInChat": "Buat di chat",
|
||||
"stats": {
|
||||
"active": "Aktif",
|
||||
"paused": "Dijeda",
|
||||
"failed": "Gagal",
|
||||
"system": "Sistem"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Semua",
|
||||
"active": "Aktif",
|
||||
"paused": "Dijeda",
|
||||
"failed": "Gagal",
|
||||
"system": "Sistem"
|
||||
},
|
||||
"search": "Cari tugas, pesan, sesi, atau ekspresi cron",
|
||||
"queue": "Antrean",
|
||||
"loading": "Memuat otomasi...",
|
||||
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
||||
"empty": "Belum ada otomasi.",
|
||||
"oneShot": "Satu kali",
|
||||
"systemTask": "Automasi yang dikelola sistem",
|
||||
"labels": {
|
||||
"schedule": "Jadwal",
|
||||
"next": "Berikutnya",
|
||||
"last": "Terakhir",
|
||||
"origin": "Asal"
|
||||
},
|
||||
"runNow": "Jalankan sekarang",
|
||||
"pause": "Jeda",
|
||||
"resume": "Lanjutkan",
|
||||
"delete": "Hapus",
|
||||
"protected": "Terlindungi",
|
||||
"deleteTitle": "Hapus otomasi",
|
||||
"deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.",
|
||||
"cancel": "Batal",
|
||||
"status": {
|
||||
"system": "Sistem",
|
||||
"pending": "Menunggu",
|
||||
"paused": "Dijeda",
|
||||
"failed": "Gagal",
|
||||
"active": "Aktif"
|
||||
},
|
||||
"origin": {
|
||||
"system": "Sistem",
|
||||
"unknown": "Sesi tidak dikenal"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "Pada {{time}}",
|
||||
"every": "Setiap {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "Jadwal khusus"
|
||||
},
|
||||
"next": {
|
||||
"paused": "Dijeda",
|
||||
"pending": "Segera berjalan",
|
||||
"none": "Tidak ada jadwal berikutnya"
|
||||
},
|
||||
"last": {
|
||||
"never": "Belum pernah",
|
||||
"unknown": "tidak dikenal"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "Autentikasi OAuth",
|
||||
"signIn": "Masuk",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "言語を変更"
|
||||
},
|
||||
"apps": "アプリ",
|
||||
"automations": "自動タスク",
|
||||
"skills": {
|
||||
"title": "スキル"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "CLI アプリ",
|
||||
"mcp": "MCP",
|
||||
"apps": "アプリ",
|
||||
"automations": "自動タスク",
|
||||
"skills": "スキル"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "一致するアプリはありません。"
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "ワークスペースの自動タスク",
|
||||
"title": "自動タスク",
|
||||
"description": "cron リマインダー、定期的な agent ターン、一回限りの自動タスク、保護されたシステム自動タスクを一か所で確認できます。",
|
||||
"refresh": "更新",
|
||||
"newInChat": "チャットで作成",
|
||||
"stats": {
|
||||
"active": "実行中",
|
||||
"paused": "一時停止",
|
||||
"failed": "失敗",
|
||||
"system": "システム"
|
||||
},
|
||||
"filters": {
|
||||
"all": "すべて",
|
||||
"active": "実行中",
|
||||
"paused": "一時停止",
|
||||
"failed": "失敗",
|
||||
"system": "システム"
|
||||
},
|
||||
"search": "タスク、メッセージ、セッション、cron 式を検索",
|
||||
"queue": "キュー",
|
||||
"loading": "自動タスクを読み込み中...",
|
||||
"noMatches": "この表示に一致する自動タスクはありません。",
|
||||
"empty": "自動タスクはまだありません。",
|
||||
"oneShot": "一回限り",
|
||||
"systemTask": "システム管理の自動タスク",
|
||||
"labels": {
|
||||
"schedule": "スケジュール",
|
||||
"next": "次回",
|
||||
"last": "前回",
|
||||
"origin": "発生元"
|
||||
},
|
||||
"runNow": "今すぐ実行",
|
||||
"pause": "一時停止",
|
||||
"resume": "再開",
|
||||
"delete": "削除",
|
||||
"protected": "保護済み",
|
||||
"deleteTitle": "自動タスクを削除",
|
||||
"deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。",
|
||||
"cancel": "キャンセル",
|
||||
"status": {
|
||||
"system": "システム",
|
||||
"pending": "待機中",
|
||||
"paused": "一時停止",
|
||||
"failed": "失敗",
|
||||
"active": "実行中"
|
||||
},
|
||||
"origin": {
|
||||
"system": "システム",
|
||||
"unknown": "不明なセッション"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "{{duration}} ごと",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "カスタムスケジュール"
|
||||
},
|
||||
"next": {
|
||||
"paused": "一時停止",
|
||||
"pending": "まもなく実行",
|
||||
"none": "次回実行なし"
|
||||
},
|
||||
"last": {
|
||||
"never": "未実行",
|
||||
"unknown": "不明"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth 認証",
|
||||
"signIn": "サインイン",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "언어 변경"
|
||||
},
|
||||
"apps": "앱",
|
||||
"automations": "자동화",
|
||||
"skills": {
|
||||
"title": "스킬"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "CLI 앱",
|
||||
"mcp": "MCP",
|
||||
"apps": "앱",
|
||||
"automations": "자동화",
|
||||
"skills": "스킬"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "일치하는 앱이 없습니다."
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "작업 공간 자동화",
|
||||
"title": "자동화",
|
||||
"description": "cron 알림, 반복 agent 작업, 일회성 자동화, 보호된 시스템 자동화를 한곳에서 확인합니다.",
|
||||
"refresh": "새로 고침",
|
||||
"newInChat": "채팅에서 만들기",
|
||||
"stats": {
|
||||
"active": "활성",
|
||||
"paused": "일시 중지",
|
||||
"failed": "실패",
|
||||
"system": "시스템"
|
||||
},
|
||||
"filters": {
|
||||
"all": "전체",
|
||||
"active": "활성",
|
||||
"paused": "일시 중지",
|
||||
"failed": "실패",
|
||||
"system": "시스템"
|
||||
},
|
||||
"search": "작업, 메시지, 세션 또는 cron 식 검색",
|
||||
"queue": "대기열",
|
||||
"loading": "자동화를 불러오는 중...",
|
||||
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
||||
"empty": "아직 자동화가 없습니다.",
|
||||
"oneShot": "일회성",
|
||||
"systemTask": "시스템 관리 자동화",
|
||||
"labels": {
|
||||
"schedule": "일정",
|
||||
"next": "다음",
|
||||
"last": "마지막",
|
||||
"origin": "출처"
|
||||
},
|
||||
"runNow": "지금 실행",
|
||||
"pause": "일시 중지",
|
||||
"resume": "재개",
|
||||
"delete": "삭제",
|
||||
"protected": "보호됨",
|
||||
"deleteTitle": "자동화 삭제",
|
||||
"deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.",
|
||||
"cancel": "취소",
|
||||
"status": {
|
||||
"system": "시스템",
|
||||
"pending": "대기 중",
|
||||
"paused": "일시 중지",
|
||||
"failed": "실패",
|
||||
"active": "활성"
|
||||
},
|
||||
"origin": {
|
||||
"system": "시스템",
|
||||
"unknown": "알 수 없는 세션"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "{{duration}}마다",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "사용자 지정 일정"
|
||||
},
|
||||
"next": {
|
||||
"paused": "일시 중지",
|
||||
"pending": "곧 실행",
|
||||
"none": "다음 실행 없음"
|
||||
},
|
||||
"last": {
|
||||
"never": "실행된 적 없음",
|
||||
"unknown": "알 수 없음"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth 인증",
|
||||
"signIn": "로그인",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Đổi ngôn ngữ"
|
||||
},
|
||||
"apps": "Ứng dụng",
|
||||
"automations": "Tự động hóa",
|
||||
"skills": {
|
||||
"title": "Kỹ năng"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "Ứng dụng CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Ứng dụng",
|
||||
"automations": "Tự động hóa",
|
||||
"skills": "Kỹ năng"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có ứng dụng phù hợp."
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "Tự động hóa không gian làm việc",
|
||||
"title": "Tự động hóa",
|
||||
"description": "Xem nhắc nhở cron, lượt agent định kỳ, tự động hóa một lần và tự động hóa hệ thống được bảo vệ tại một nơi.",
|
||||
"refresh": "Làm mới",
|
||||
"newInChat": "Tạo trong chat",
|
||||
"stats": {
|
||||
"active": "Đang chạy",
|
||||
"paused": "Đã tạm dừng",
|
||||
"failed": "Thất bại",
|
||||
"system": "Hệ thống"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Tất cả",
|
||||
"active": "Đang chạy",
|
||||
"paused": "Đã tạm dừng",
|
||||
"failed": "Thất bại",
|
||||
"system": "Hệ thống"
|
||||
},
|
||||
"search": "Tìm tác vụ, tin nhắn, phiên hoặc biểu thức cron",
|
||||
"queue": "Hàng đợi",
|
||||
"loading": "Đang tải tự động hóa...",
|
||||
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
|
||||
"empty": "Chưa có tự động hóa.",
|
||||
"oneShot": "Một lần",
|
||||
"systemTask": "Tự động hóa do hệ thống quản lý",
|
||||
"labels": {
|
||||
"schedule": "Lịch",
|
||||
"next": "Tiếp theo",
|
||||
"last": "Lần trước",
|
||||
"origin": "Nguồn"
|
||||
},
|
||||
"runNow": "Chạy ngay",
|
||||
"pause": "Tạm dừng",
|
||||
"resume": "Tiếp tục",
|
||||
"delete": "Xóa",
|
||||
"protected": "Được bảo vệ",
|
||||
"deleteTitle": "Xóa tự động hóa",
|
||||
"deleteDescription": "Thao tác này xóa {{name}} khỏi kho cron. Tin nhắn chat trước đó vẫn ở trong phiên.",
|
||||
"cancel": "Hủy",
|
||||
"status": {
|
||||
"system": "Hệ thống",
|
||||
"pending": "Đang chờ",
|
||||
"paused": "Đã tạm dừng",
|
||||
"failed": "Thất bại",
|
||||
"active": "Đang chạy"
|
||||
},
|
||||
"origin": {
|
||||
"system": "Hệ thống",
|
||||
"unknown": "Phiên không xác định"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "Vào {{time}}",
|
||||
"every": "Mỗi {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "Lịch tùy chỉnh"
|
||||
},
|
||||
"next": {
|
||||
"paused": "Đã tạm dừng",
|
||||
"pending": "Sắp chạy",
|
||||
"none": "Không có lần chạy tiếp theo"
|
||||
},
|
||||
"last": {
|
||||
"never": "Chưa từng chạy",
|
||||
"unknown": "không xác định"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "Xác thực OAuth",
|
||||
"signIn": "Đăng nhập",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "切换语言"
|
||||
},
|
||||
"apps": "应用",
|
||||
"automations": "自动任务",
|
||||
"skills": {
|
||||
"title": "技能"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"runtime": "系统",
|
||||
"advanced": "安全",
|
||||
"apps": "应用",
|
||||
"automations": "自动任务",
|
||||
"skills": "技能"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "没有匹配的应用。"
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "工作区自动任务",
|
||||
"title": "自动任务",
|
||||
"description": "统一查看 cron 提醒、周期性 agent 任务、一次性自动任务和受保护的系统自动任务。",
|
||||
"refresh": "刷新",
|
||||
"newInChat": "在聊天中创建",
|
||||
"stats": {
|
||||
"active": "运行中",
|
||||
"paused": "已暂停",
|
||||
"failed": "失败",
|
||||
"system": "系统"
|
||||
},
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
"active": "运行中",
|
||||
"paused": "已暂停",
|
||||
"failed": "失败",
|
||||
"system": "系统"
|
||||
},
|
||||
"search": "搜索任务、消息、会话或 cron 表达式",
|
||||
"queue": "任务队列",
|
||||
"loading": "正在加载自动任务...",
|
||||
"noMatches": "当前视图没有匹配的自动任务。",
|
||||
"empty": "暂无自动任务。",
|
||||
"oneShot": "一次性",
|
||||
"systemTask": "系统管理的自动任务",
|
||||
"labels": {
|
||||
"schedule": "计划",
|
||||
"next": "下次",
|
||||
"last": "上次",
|
||||
"origin": "来源"
|
||||
},
|
||||
"runNow": "立即运行",
|
||||
"pause": "暂停",
|
||||
"resume": "恢复",
|
||||
"delete": "删除",
|
||||
"protected": "受保护",
|
||||
"deleteTitle": "删除自动任务",
|
||||
"deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。",
|
||||
"cancel": "取消",
|
||||
"status": {
|
||||
"system": "系统",
|
||||
"pending": "等待中",
|
||||
"paused": "已暂停",
|
||||
"failed": "失败",
|
||||
"active": "运行中"
|
||||
},
|
||||
"origin": {
|
||||
"system": "系统",
|
||||
"unknown": "未知会话"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "在 {{time}}",
|
||||
"every": "每 {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "自定义计划"
|
||||
},
|
||||
"next": {
|
||||
"paused": "已暂停",
|
||||
"pending": "即将运行",
|
||||
"none": "没有下次运行"
|
||||
},
|
||||
"last": {
|
||||
"never": "从未运行",
|
||||
"unknown": "未知"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth 认证",
|
||||
"signIn": "登录",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "切換語言"
|
||||
},
|
||||
"apps": "應用",
|
||||
"automations": "自動任務",
|
||||
"skills": {
|
||||
"title": "技能"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "CLI 應用",
|
||||
"mcp": "MCP",
|
||||
"apps": "應用",
|
||||
"automations": "自動任務",
|
||||
"skills": "技能"
|
||||
},
|
||||
"sections": {
|
||||
@@ -461,6 +463,74 @@
|
||||
"loading": "正在載入應用...",
|
||||
"empty": "沒有符合的應用。"
|
||||
},
|
||||
"automations": {
|
||||
"kicker": "工作區自動任務",
|
||||
"title": "自動任務",
|
||||
"description": "集中查看 cron 提醒、週期性 agent 任務、一次性自動任務和受保護的系統自動任務。",
|
||||
"refresh": "重新整理",
|
||||
"newInChat": "在聊天中建立",
|
||||
"stats": {
|
||||
"active": "執行中",
|
||||
"paused": "已暫停",
|
||||
"failed": "失敗",
|
||||
"system": "系統"
|
||||
},
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
"active": "執行中",
|
||||
"paused": "已暫停",
|
||||
"failed": "失敗",
|
||||
"system": "系統"
|
||||
},
|
||||
"search": "搜尋任務、訊息、會話或 cron 表達式",
|
||||
"queue": "任務佇列",
|
||||
"loading": "正在載入自動任務...",
|
||||
"noMatches": "目前檢視沒有符合的自動任務。",
|
||||
"empty": "尚無自動任務。",
|
||||
"oneShot": "一次性",
|
||||
"systemTask": "系統管理的自動任務",
|
||||
"labels": {
|
||||
"schedule": "排程",
|
||||
"next": "下次",
|
||||
"last": "上次",
|
||||
"origin": "來源"
|
||||
},
|
||||
"runNow": "立即執行",
|
||||
"pause": "暫停",
|
||||
"resume": "恢復",
|
||||
"delete": "刪除",
|
||||
"protected": "受保護",
|
||||
"deleteTitle": "刪除自動任務",
|
||||
"deleteDescription": "這會從 cron 儲存中刪除 {{name}},歷史聊天訊息會保留在會話中。",
|
||||
"cancel": "取消",
|
||||
"status": {
|
||||
"system": "系統",
|
||||
"pending": "等待中",
|
||||
"paused": "已暫停",
|
||||
"failed": "失敗",
|
||||
"active": "執行中"
|
||||
},
|
||||
"origin": {
|
||||
"system": "系統",
|
||||
"unknown": "未知會話"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "於 {{time}}",
|
||||
"every": "每 {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"custom": "自訂排程"
|
||||
},
|
||||
"next": {
|
||||
"paused": "已暫停",
|
||||
"pending": "即將執行",
|
||||
"none": "沒有下次執行"
|
||||
},
|
||||
"last": {
|
||||
"never": "從未執行",
|
||||
"unknown": "未知"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth 認證",
|
||||
"signIn": "登入",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AutomationsPayload,
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
FilePreviewPayload,
|
||||
@@ -184,6 +185,34 @@ export async function fetchSessionAutomations(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAutomations(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<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(
|
||||
token: string,
|
||||
base: string = "",
|
||||
|
||||
@@ -100,6 +100,10 @@ export interface SessionAutomationJob {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
protected?: boolean;
|
||||
delete_after_run?: boolean;
|
||||
created_at_ms?: number | null;
|
||||
updated_at_ms?: number | null;
|
||||
schedule: {
|
||||
kind: "at" | "every" | "cron" | string;
|
||||
at_ms?: number | null;
|
||||
@@ -109,15 +113,35 @@ export interface SessionAutomationJob {
|
||||
};
|
||||
payload: {
|
||||
message: string;
|
||||
kind?: "agent_turn" | "system_event" | string;
|
||||
session_key?: string | null;
|
||||
origin_channel?: string | null;
|
||||
origin_chat_id?: string | null;
|
||||
};
|
||||
state: {
|
||||
next_run_at_ms?: number | null;
|
||||
last_run_at_ms?: number | null;
|
||||
last_status?: "ok" | "error" | "skipped" | string | null;
|
||||
last_error?: string | null;
|
||||
pending?: boolean;
|
||||
run_history?: Array<{
|
||||
run_at_ms: number;
|
||||
status: "ok" | "error" | "skipped" | string;
|
||||
duration_ms?: number;
|
||||
error?: string | null;
|
||||
}>;
|
||||
};
|
||||
origin?: {
|
||||
session_key: string;
|
||||
channel: string;
|
||||
chat_id: string;
|
||||
title?: string;
|
||||
preview?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
|
||||
export interface AutomationsPayload { jobs: SessionAutomationJob[]; }
|
||||
|
||||
export interface SessionDeleteResult {
|
||||
deleted: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchAutomations,
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
listSlashCommands,
|
||||
loginProviderOAuth,
|
||||
logoutProviderOAuth,
|
||||
runAutomationAction,
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
@@ -99,6 +101,28 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches workspace automations", async () => {
|
||||
await fetchAutomations("tok");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes workspace automation actions", async () => {
|
||||
await runAutomationAction("tok", "disable", "job 1/2");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/disable?id=job+1%2F2",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches the WebUI skill summary", async () => {
|
||||
await fetchSkills("tok");
|
||||
|
||||
|
||||
@@ -334,6 +334,96 @@ describe("App layout", () => {
|
||||
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Automations from the main sidebar", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/webui/automations": {
|
||||
jobs: [
|
||||
{
|
||||
id: "job-1",
|
||||
name: "Daily repo check",
|
||||
enabled: true,
|
||||
protected: false,
|
||||
delete_after_run: false,
|
||||
schedule: { kind: "every", every_ms: 86_400_000 },
|
||||
payload: {
|
||||
message: "Check the repo status",
|
||||
kind: "agent_turn",
|
||||
session_key: "websocket:chat-a",
|
||||
},
|
||||
state: {
|
||||
next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0),
|
||||
last_status: "ok",
|
||||
pending: false,
|
||||
run_history: [],
|
||||
},
|
||||
origin: {
|
||||
session_key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chat_id: "chat-a",
|
||||
title: "Release prep",
|
||||
preview: "Check release blockers",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "heartbeat",
|
||||
name: "heartbeat",
|
||||
enabled: true,
|
||||
protected: true,
|
||||
schedule: { kind: "every", every_ms: 60_000 },
|
||||
payload: { message: "", kind: "system_event" },
|
||||
state: { next_run_at_ms: null, pending: false, run_history: [] },
|
||||
origin: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<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 () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ const SETTINGS_NAV_KEYS = [
|
||||
"image",
|
||||
"browser",
|
||||
"apps",
|
||||
"automations",
|
||||
"runtime",
|
||||
"advanced",
|
||||
];
|
||||
@@ -43,8 +44,21 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.nav.models",
|
||||
"settings.nav.providers",
|
||||
"settings.nav.apps",
|
||||
"settings.nav.automations",
|
||||
"settings.nav.runtime",
|
||||
"settings.nav.advanced",
|
||||
"sidebar.automations",
|
||||
"settings.automations.title",
|
||||
"settings.automations.description",
|
||||
"settings.automations.refresh",
|
||||
"settings.automations.newInChat",
|
||||
"settings.automations.filters.active",
|
||||
"settings.automations.queue",
|
||||
"settings.automations.empty",
|
||||
"settings.automations.systemTask",
|
||||
"settings.automations.labels.schedule",
|
||||
"settings.automations.status.active",
|
||||
"settings.automations.deleteTitle",
|
||||
"settings.sections.interface",
|
||||
"settings.sections.localPreferences",
|
||||
"settings.sections.webSearch",
|
||||
|
||||
Reference in New Issue
Block a user