feat(webui): add automation management view

This commit is contained in:
chengyongru
2026-06-13 23:06:28 +08:00
parent be6419b289
commit e08462ca30
20 changed files with 1710 additions and 8 deletions
+116 -4
View File
@@ -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 ""
+95
View File
@@ -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
View File
@@ -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,
+10 -1
View File
@@ -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,
+70
View File
@@ -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",
+70
View File
@@ -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",
+70
View File
@@ -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 lespace",
"title": "Automatisations",
"description": "Passez en revue les rappels cron, tours récurrents de lagent, 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 lautomatisation",
"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",
+70
View File
@@ -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",
+70
View File
@@ -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": "サインイン",
+70
View File
@@ -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": "로그인",
+70
View File
@@ -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",
+70
View File
@@ -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": "登录",
+70
View File
@@ -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": "登入",
+29
View File
@@ -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 = "",
+24
View File
@@ -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;
+24
View File
@@ -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");
+90
View File
@@ -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 = [
{
+14
View File
@@ -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",