fix(webui): improve automation management

This commit is contained in:
chengyongru
2026-06-14 18:24:59 +08:00
parent 6cf1f8e164
commit 747f0a08c7
15 changed files with 1376 additions and 91 deletions
+85 -1
View File
@@ -23,6 +23,7 @@ from websockets.http11 import Request as WsRequest
from websockets.http11 import Response from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette from nanobot.command.builtin import builtin_command_palette
from nanobot.cron.types import CronSchedule
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
@@ -80,6 +81,7 @@ from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000 _SLOW_WEBUI_HTTP_LOG_MS = 1_000
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -529,7 +531,7 @@ class GatewayHTTPHandler:
) -> Response | None: ) -> Response | None:
if got == "/api/webui/automations": if got == "/api/webui/automations":
return self._handle_webui_automations(request) return self._handle_webui_automations(request)
m = re.match(r"^/api/webui/automations/(enable|disable|delete|run)$", got) m = re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", got)
if m: if m:
return await self._handle_webui_automation_action(request, m.group(1)) return await self._handle_webui_automation_action(request, m.group(1))
return None return None
@@ -594,6 +596,21 @@ class GatewayHTTPHandler:
return _http_error(409, "automation is disabled") return _http_error(409, "automation is disabled")
task = asyncio.create_task(self.cron_service.run_job(job_id, force=False)) task = asyncio.create_task(self.cron_service.run_job(job_id, force=False))
task.add_done_callback(self._log_automation_run_result) task.add_done_callback(self._log_automation_run_result)
elif action == "update":
values = _automation_values_from_request(request)
if values is None:
return _http_error(400, "invalid automation update payload")
parsed = _parse_automation_update(values)
if isinstance(parsed, str):
return _http_error(400, parsed)
try:
result = self.cron_service.update_job(job_id, **parsed)
except ValueError as exc:
return _http_error(400, str(exc))
if result == "not_found":
return _http_error(404, "automation not found")
if result == "protected":
return _http_error(403, "system automation is protected")
else: else:
return _http_error(404, "unknown automation action") return _http_error(404, "unknown automation action")
@@ -757,5 +774,72 @@ class GatewayHTTPHandler:
extra_headers=[("Cache-Control", cache)], extra_headers=[("Cache-Control", cache)],
) )
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
if not raw:
return {}
try:
values = json.loads(raw)
except Exception:
return None
return values if isinstance(values, dict) else None
def _parse_automation_update(values: dict[str, Any]) -> dict[str, Any] | str:
update: dict[str, Any] = {}
if "name" in values:
name = str(values.get("name") or "").strip()
if not name:
return "name cannot be empty"
update["name"] = name
if "message" in values:
message = str(values.get("message") or "").strip()
if not message:
return "message cannot be empty"
update["message"] = message
if "schedule" in values:
raw_schedule = values.get("schedule")
if not isinstance(raw_schedule, dict):
return "schedule must be an object"
parsed_schedule = _parse_automation_schedule(raw_schedule)
if isinstance(parsed_schedule, str):
return parsed_schedule
update["schedule"] = parsed_schedule
update["delete_after_run"] = parsed_schedule.kind == "at"
return update
def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
kind = str(values.get("kind") or "").strip()
if kind == "every":
every_ms = _positive_int(values.get("every_ms"))
if every_ms is None:
return "every schedule requires positive every_ms"
return CronSchedule(kind="every", every_ms=every_ms)
if kind == "cron":
expr = str(values.get("expr") or "").strip()
if not expr:
return "cron schedule requires expr"
tz = str(values.get("tz") or "").strip() or None
return CronSchedule(kind="cron", expr=expr, tz=tz)
if kind == "at":
at_ms = _positive_int(values.get("at_ms"))
if at_ms is None:
return "one-time schedule requires positive at_ms"
return CronSchedule(kind="at", at_ms=at_ms)
return "unknown schedule kind"
def _positive_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _is_websocket_channel_session_key(key: str) -> bool: def _is_websocket_channel_session_key(key: str) -> bool:
return key.startswith("websocket:") return key.startswith("websocket:")
+62 -12
View File
@@ -3,6 +3,8 @@
import asyncio import asyncio
import functools import functools
import json import json
import random
import socket
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -23,6 +25,18 @@ from nanobot.webui.gateway_services import GatewayServices, build_gateway_servic
_PORT = 29900 _PORT = 29900
def _free_port() -> int:
for _ in range(100):
port = random.randint(30_000, 60_000)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("127.0.0.1", port))
except OSError:
continue
return port
raise RuntimeError("could not find a free localhost port")
def _make_handler( def _make_handler(
cfg: dict[str, Any] | WebSocketConfig, cfg: dict[str, Any] | WebSocketConfig,
bus: Any, bus: Any,
@@ -817,6 +831,8 @@ async def test_session_delete_removes_file(
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
bus: MagicMock, tmp_path: Path bus: MagicMock, tmp_path: Path
) -> None: ) -> None:
port = _free_port()
base_url = f"http://127.0.0.1:{port}"
cron = CronService(tmp_path / "cron" / "jobs.json") cron = CronService(tmp_path / "cron" / "jobs.json")
user_job = cron.add_job( user_job = cron.add_job(
name="Daily repo check", name="Daily repo check",
@@ -857,19 +873,19 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
session_manager=session_manager, session_manager=session_manager,
cron_service=cron, cron_service=cron,
cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(), cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(),
port=29932, port=port,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
try: try:
deny = await _http_get("http://127.0.0.1:29932/api/webui/automations") deny = await _http_get(f"{base_url}/api/webui/automations")
assert deny.status_code == 401 assert deny.status_code == 401, deny.text
boot = await _http_get("http://127.0.0.1:29932/webui/bootstrap") boot = await _http_get(f"{base_url}/webui/bootstrap")
token = boot.json()["token"] token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get( resp = await _http_get(
"http://127.0.0.1:29932/api/webui/automations", f"{base_url}/api/webui/automations",
headers=auth, headers=auth,
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -886,8 +902,42 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
assert by_id[external_job.id]["origin"]["preview"] == "" assert by_id[external_job.id]["origin"]["preview"] == ""
assert by_id["heartbeat"]["protected"] is True assert by_id["heartbeat"]["protected"] is True
updated = await _http_get(
f"{base_url}/api/webui/automations/update?id={user_job.id}",
headers={
**auth,
"X-Nanobot-Automation-Values": json.dumps(
{
"name": "Daily quiz",
"message": "Ask the daily quiz",
"schedule": {
"kind": "cron",
"expr": "0 9 * * *",
"tz": "UTC",
},
}
),
},
)
assert updated.status_code == 200
by_id = {job["id"]: job for job in updated.json()["jobs"]}
assert by_id[user_job.id]["name"] == "Daily quiz"
assert by_id[user_job.id]["payload"]["message"] == "Ask the daily quiz"
assert by_id[user_job.id]["schedule"]["kind"] == "cron"
assert by_id[user_job.id]["schedule"]["expr"] == "0 9 * * *"
assert by_id[user_job.id]["schedule"]["tz"] == "UTC"
protected_update = await _http_get(
f"{base_url}/api/webui/automations/update?id=heartbeat",
headers={
**auth,
"X-Nanobot-Automation-Values": json.dumps({"name": "bad"}),
},
)
assert protected_update.status_code == 403
disabled = await _http_get( disabled = await _http_get(
f"http://127.0.0.1:29932/api/webui/automations/disable?id={user_job.id}", f"{base_url}/api/webui/automations/disable?id={user_job.id}",
headers=auth, headers=auth,
) )
assert disabled.status_code == 200 assert disabled.status_code == 200
@@ -895,29 +945,29 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
assert by_id[user_job.id]["enabled"] is False assert by_id[user_job.id]["enabled"] is False
disabled_run = await _http_get( disabled_run = await _http_get(
f"http://127.0.0.1:29932/api/webui/automations/run?id={user_job.id}", f"{base_url}/api/webui/automations/run?id={user_job.id}",
headers=auth, headers=auth,
) )
assert disabled_run.status_code == 409 assert disabled_run.status_code == 409
protected_delete = await _http_get( protected_delete = await _http_get(
"http://127.0.0.1:29932/api/webui/automations/delete?id=heartbeat", f"{base_url}/api/webui/automations/delete?id=heartbeat",
headers=auth, headers=auth,
) )
assert protected_delete.status_code == 403 assert protected_delete.status_code == 403
protected_disable = await _http_get( protected_disable = await _http_get(
"http://127.0.0.1:29932/api/webui/automations/disable?id=heartbeat", f"{base_url}/api/webui/automations/disable?id=heartbeat",
headers=auth, headers=auth,
) )
assert protected_disable.status_code == 403 assert protected_disable.status_code == 403
protected_run = await _http_get( protected_run = await _http_get(
"http://127.0.0.1:29932/api/webui/automations/run?id=heartbeat", f"{base_url}/api/webui/automations/run?id=heartbeat",
headers=auth, headers=auth,
) )
assert protected_run.status_code == 403 assert protected_run.status_code == 403
enabled = await _http_get( enabled = await _http_get(
f"http://127.0.0.1:29932/api/webui/automations/enable?id={user_job.id}", f"{base_url}/api/webui/automations/enable?id={user_job.id}",
headers=auth, headers=auth,
) )
assert enabled.status_code == 200 assert enabled.status_code == 200
@@ -925,7 +975,7 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
assert by_id[user_job.id]["enabled"] is True assert by_id[user_job.id]["enabled"] is True
deleted = await _http_get( deleted = await _http_get(
f"http://127.0.0.1:29932/api/webui/automations/delete?id={user_job.id}", f"{base_url}/api/webui/automations/delete?id={user_job.id}",
headers=auth, headers=auth,
) )
assert deleted.status_code == 200 assert deleted.status_code == 200
+643 -42
View File
@@ -5,12 +5,14 @@ import {
useMemo, useMemo,
useState, useState,
type Dispatch, type Dispatch,
type FormEvent,
type ReactNode, type ReactNode,
type SetStateAction, type SetStateAction,
} from "react"; } from "react";
import { import {
Activity, Activity,
ArrowUpCircle, ArrowUpCircle,
ArrowUpDown,
Bot, Bot,
Brain, Brain,
Check, Check,
@@ -93,6 +95,7 @@ import {
runCliAppAction, runCliAppAction,
runMcpPresetAction, runMcpPresetAction,
saveCustomMcpServer, saveCustomMcpServer,
updateAutomation,
updateImageGenerationSettings, updateImageGenerationSettings,
updateMcpServerTools, updateMcpServerTools,
updateModelConfiguration, updateModelConfiguration,
@@ -116,6 +119,7 @@ import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
import type { import type {
AutomationsPayload, AutomationsPayload,
AutomationUpdatePayload,
CliAppInfo, CliAppInfo,
CliAppsPayload, CliAppsPayload,
ImageGenerationSettingsUpdate, ImageGenerationSettingsUpdate,
@@ -148,6 +152,7 @@ type LocalDensity = "comfortable" | "compact";
type LocalActivityMode = "auto" | "expanded"; type LocalActivityMode = "auto" | "expanded";
type AppsKindFilter = "all" | "cli" | "mcp"; type AppsKindFilter = "all" | "cli" | "mcp";
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
type AutomationSort = "next" | "last" | "updated" | "name";
type AutomationAction = "enable" | "disable" | "delete" | "run"; type AutomationAction = "enable" | "disable" | "delete" | "run";
type AppsCatalogItem = type AppsCatalogItem =
| { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "cli"; app: CliAppInfo }
@@ -546,6 +551,7 @@ export function SettingsView({
const [appsQuery, setAppsQuery] = useState(""); const [appsQuery, setAppsQuery] = useState("");
const [automationsQuery, setAutomationsQuery] = useState(""); const [automationsQuery, setAutomationsQuery] = useState("");
const [automationsFilter, setAutomationsFilter] = useState<AutomationFilter>("all"); const [automationsFilter, setAutomationsFilter] = useState<AutomationFilter>("all");
const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null); const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null); const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null); const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
@@ -556,6 +562,8 @@ export function SettingsView({
const [automationAction, setAutomationAction] = useState<string | null>(null); const [automationAction, setAutomationAction] = useState<string | null>(null);
const [automationPendingDelete, setAutomationPendingDelete] = const [automationPendingDelete, setAutomationPendingDelete] =
useState<SessionAutomationJob | null>(null); useState<SessionAutomationJob | null>(null);
const [automationPendingEdit, setAutomationPendingEdit] =
useState<SessionAutomationJob | null>(null);
const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({}); const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({});
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM); const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
const [mcpConfigImport, setMcpConfigImport] = useState(""); const [mcpConfigImport, setMcpConfigImport] = useState("");
@@ -718,25 +726,51 @@ export function SettingsView({
}; };
}, [activeSection, token]); }, [activeSection, token]);
const refreshAutomations = useCallback(
async (showLoading = false) => {
if (showLoading) setAutomationsLoading(true);
try {
const payload = await fetchAutomations(token);
setAutomations(payload);
setAutomationsError(null);
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
if (showLoading) setAutomationsLoading(false);
}
},
[token],
);
useEffect(() => { useEffect(() => {
if (activeSection !== "automations") return; if (activeSection !== "automations") return;
let cancelled = false; let cancelled = false;
setAutomationsLoading(true); const refresh = async (showLoading = false) => {
fetchAutomations(token) if (cancelled) return;
.then((payload) => { if (showLoading) setAutomationsLoading(true);
if (!cancelled) { try {
setAutomations(payload); const payload = await fetchAutomations(token);
setAutomationsError(null); if (cancelled) return;
} setAutomations(payload);
}) setAutomationsError(null);
.catch((err) => { } catch (err) {
if (!cancelled) setAutomationsError((err as Error).message); if (!cancelled) setAutomationsError((err as Error).message);
}) } finally {
.finally(() => { if (!cancelled && showLoading) setAutomationsLoading(false);
if (!cancelled) setAutomationsLoading(false); }
}); };
void refresh(true);
const interval = window.setInterval(() => void refresh(false), 5000);
const refreshOnFocus = () => {
if (document.visibilityState !== "hidden") void refresh(false);
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => { return () => {
cancelled = true; cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
}; };
}, [activeSection, token]); }, [activeSection, token]);
@@ -1275,6 +1309,28 @@ export function SettingsView({
const payload = await runAutomationAction(token, action, job.id); const payload = await runAutomationAction(token, action, job.id);
setAutomations(payload); setAutomations(payload);
if (action === "delete") setAutomationPendingDelete(null); if (action === "delete") setAutomationPendingDelete(null);
if (action === "run") {
window.setTimeout(() => void refreshAutomations(false), 1200);
window.setTimeout(() => void refreshAutomations(false), 4000);
}
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
setAutomationAction(null);
}
};
const handleAutomationEdit = async (
job: SessionAutomationJob,
values: AutomationUpdatePayload,
) => {
const key = `update:${job.id}`;
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await updateAutomation(token, job.id, values);
setAutomations(payload);
setAutomationPendingEdit(null);
} catch (err) { } catch (err) {
setAutomationsError((err as Error).message); setAutomationsError((err as Error).message);
} finally { } finally {
@@ -1569,11 +1625,14 @@ export function SettingsView({
loading={automationsLoading} loading={automationsLoading}
query={automationsQuery} query={automationsQuery}
filter={automationsFilter} filter={automationsFilter}
sort={automationsSort}
actionKey={automationAction} actionKey={automationAction}
error={automationsError} error={automationsError}
onQueryChange={setAutomationsQuery} onQueryChange={setAutomationsQuery}
onFilterChange={setAutomationsFilter} onFilterChange={setAutomationsFilter}
onSortChange={setAutomationsSort}
onAction={handleAutomationAction} onAction={handleAutomationAction}
onRequestEdit={setAutomationPendingEdit}
onRequestDelete={setAutomationPendingDelete} onRequestDelete={setAutomationPendingDelete}
/> />
); );
@@ -1644,6 +1703,15 @@ export function SettingsView({
onConfirm={(job) => handleAutomationAction("delete", job)} onConfirm={(job) => handleAutomationAction("delete", job)}
/> />
<AutomationEditDialog
job={automationPendingEdit}
saving={automationAction === `update:${automationPendingEdit?.id ?? ""}`}
onOpenChange={(open) => {
if (!open) setAutomationPendingEdit(null);
}}
onSave={handleAutomationEdit}
/>
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]"> <main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<div <div
className={cn( className={cn(
@@ -3333,22 +3401,28 @@ function AutomationsSettings({
loading, loading,
query, query,
filter, filter,
sort,
actionKey, actionKey,
error, error,
onQueryChange, onQueryChange,
onFilterChange, onFilterChange,
onSortChange,
onAction, onAction,
onRequestEdit,
onRequestDelete, onRequestDelete,
}: { }: {
payload: AutomationsPayload | null; payload: AutomationsPayload | null;
loading: boolean; loading: boolean;
query: string; query: string;
filter: AutomationFilter; filter: AutomationFilter;
sort: AutomationSort;
actionKey: string | null; actionKey: string | null;
error: string | null; error: string | null;
onQueryChange: (value: string) => void; onQueryChange: (value: string) => void;
onFilterChange: (value: AutomationFilter) => void; onFilterChange: (value: AutomationFilter) => void;
onSortChange: (value: AutomationSort) => void;
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>; onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
onRequestEdit: (job: SessionAutomationJob) => void;
onRequestDelete: (job: SessionAutomationJob) => void; onRequestDelete: (job: SessionAutomationJob) => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -3356,20 +3430,29 @@ function AutomationsSettings({
t(key, { defaultValue: fallback, ...(values ?? {}) }); t(key, { defaultValue: fallback, ...(values ?? {}) });
const jobs = payload?.jobs ?? []; const jobs = payload?.jobs ?? [];
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const filtered = jobs const filtered = sortAutomationJobs(jobs, sort)
.filter((job) => automationMatchesFilter(job, filter)) .filter((job) => automationMatchesFilter(job, filter))
.filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery)); .filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery));
const activeCount = jobs.filter((job) => job.enabled && !job.protected).length; const activeCount = jobs.filter((job) => {
const pausedCount = jobs.filter((job) => !job.enabled && !job.protected).length; const key = automationStatusKey(job);
const failedCount = jobs.filter((job) => job.state.last_status === "error").length; return key === "active" || key === "running";
}).length;
const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length;
const failedCount = jobs.filter(automationNeedsAttention).length;
const systemCount = jobs.filter((job) => job.protected).length; const systemCount = jobs.filter((job) => job.protected).length;
const filterOptions = [ const filterOptions = [
{ value: "all", label: tx("settings.automations.filters.all", "All") }, { value: "all", label: tx("settings.automations.filters.all", "All") },
{ value: "active", label: tx("settings.automations.filters.active", "Active") }, { value: "active", label: tx("settings.automations.filters.active", "Active") },
{ value: "paused", label: tx("settings.automations.filters.paused", "Paused") }, { value: "paused", label: tx("settings.automations.filters.paused", "Paused") },
{ value: "failed", label: tx("settings.automations.filters.failed", "Failed") }, { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention") },
{ value: "system", label: tx("settings.automations.filters.system", "System") }, { value: "system", label: tx("settings.automations.filters.system", "System") },
]; ];
const sortLabel = {
next: tx("settings.automations.sort.next", "Next run"),
last: tx("settings.automations.sort.last", "Last run"),
updated: tx("settings.automations.sort.updated", "Updated"),
name: tx("settings.automations.sort.name", "Name"),
} satisfies Record<AutomationSort, string>;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -3377,7 +3460,7 @@ function AutomationsSettings({
<div className="grid gap-2 sm:grid-cols-4"> <div className="grid gap-2 sm:grid-cols-4">
<AutomationStat label={tx("settings.automations.stats.active", "Active")} value={activeCount} /> <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.paused", "Paused")} value={pausedCount} />
<AutomationStat label={tx("settings.automations.stats.failed", "Failed")} value={failedCount} /> <AutomationStat label={tx("settings.automations.stats.failed", "Needs attention")} value={failedCount} />
<AutomationStat label={tx("settings.automations.stats.system", "System")} value={systemCount} /> <AutomationStat label={tx("settings.automations.stats.system", "System")} value={systemCount} />
</div> </div>
@@ -3391,11 +3474,33 @@ function AutomationsSettings({
className="h-9 rounded-full bg-background/85 pl-9 text-[13px]" className="h-9 rounded-full bg-background/85 pl-9 text-[13px]"
/> />
</div> </div>
<SegmentedControl <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
value={filter} <DropdownMenu>
options={filterOptions} <DropdownMenuTrigger asChild>
onChange={(value) => onFilterChange(value as AutomationFilter)} <button
/> type="button"
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-border/55 bg-background/85 px-3 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/70 hover:text-foreground"
>
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
<span>{sortLabel[sort]}</span>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
<span>{sortLabel[value]}</span>
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<SegmentedControl
value={filter}
options={filterOptions}
onChange={(value) => onFilterChange(value as AutomationFilter)}
/>
</div>
</div> </div>
</section> </section>
@@ -3422,15 +3527,26 @@ function AutomationsSettings({
locale={i18n.resolvedLanguage || i18n.language} locale={i18n.resolvedLanguage || i18n.language}
actionKey={actionKey} actionKey={actionKey}
onAction={onAction} onAction={onAction}
onRequestEdit={onRequestEdit}
onRequestDelete={onRequestDelete} onRequestDelete={onRequestDelete}
/> />
))} ))}
</div> </div>
) : ( ) : (
<div className="rounded-[22px] border border-border/45 bg-card/78 px-5 py-10 text-center text-[13px] text-muted-foreground"> <div className="rounded-[22px] border border-border/45 bg-card/78 px-5 py-10 text-center text-[13px] text-muted-foreground">
{jobs.length <div>
? tx("settings.automations.noMatches", "No automations match this view.") {jobs.length
: tx("settings.automations.empty", "No automations yet.")} ? tx("settings.automations.noMatches", "No automations match this view.")
: tx("settings.automations.empty", "No automations yet.")}
</div>
{!jobs.length ? (
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.automations.emptyHint",
"Create one from the chat or channel where it should run so nanobot keeps the right context.",
)}
</div>
) : null}
</div> </div>
)} )}
</section> </section>
@@ -3452,12 +3568,14 @@ function AutomationRow({
locale, locale,
actionKey, actionKey,
onAction, onAction,
onRequestEdit,
onRequestDelete, onRequestDelete,
}: { }: {
job: SessionAutomationJob; job: SessionAutomationJob;
locale: string; locale: string;
actionKey: string | null; actionKey: string | null;
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>; onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
onRequestEdit: (job: SessionAutomationJob) => void;
onRequestDelete: (job: SessionAutomationJob) => void; onRequestDelete: (job: SessionAutomationJob) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -3473,6 +3591,7 @@ function AutomationRow({
const canRun = canManage && job.enabled && !job.state.pending; const canRun = canManage && job.enabled && !job.state.pending;
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable"; const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
const toggleBusy = actionKey === `${toggleAction}:${job.id}`; const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
const needsRecreation = automationNeedsRecreation(job);
return ( 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"> <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">
@@ -3492,16 +3611,25 @@ function AutomationRow({
</p> </p>
<div className="mt-3 grid gap-2 text-[12px] text-muted-foreground md:grid-cols-2 xl:grid-cols-4"> <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")}> <AutomationDetail
label={tx("settings.automations.labels.schedule", "Schedule")}
title={formatAutomationSchedule(job, locale, tx)}
>
{formatAutomationSchedule(job, locale, tx)} {formatAutomationSchedule(job, locale, tx)}
</AutomationDetail> </AutomationDetail>
<AutomationDetail label={tx("settings.automations.labels.next", "Next")}> <AutomationDetail
label={tx("settings.automations.labels.next", "Next")}
title={formatAutomationNextTitle(job, locale, tx)}
>
{formatAutomationNext(job, tx)} {formatAutomationNext(job, tx)}
</AutomationDetail> </AutomationDetail>
<AutomationDetail label={tx("settings.automations.labels.last", "Last")}> <AutomationDetail
label={tx("settings.automations.labels.last", "Last")}
title={formatAutomationLast(job, locale, tx)}
>
{formatAutomationLast(job, locale, tx)} {formatAutomationLast(job, locale, tx)}
</AutomationDetail> </AutomationDetail>
<AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")}> <AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}>
{originHref ? ( {originHref ? (
<a <a
className="inline-flex max-w-full items-center gap-1 text-foreground/80 underline-offset-2 hover:underline" className="inline-flex max-w-full items-center gap-1 text-foreground/80 underline-offset-2 hover:underline"
@@ -3516,6 +3644,15 @@ function AutomationRow({
</AutomationDetail> </AutomationDetail>
</div> </div>
{needsRecreation ? (
<div className="mt-3 rounded-[14px] border border-amber-500/20 bg-amber-500/8 px-3 py-2 text-[12px] leading-5 text-amber-800 dark:text-amber-200">
{tx(
"settings.automations.legacyWarning",
"This older automation is missing its target chat. Recreate it from the chat or channel where it should run.",
)}
</div>
) : null}
{job.state.last_error ? ( {job.state.last_error ? (
<div className="mt-3 rounded-[14px] bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive"> <div className="mt-3 rounded-[14px] bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
{job.state.last_error} {job.state.last_error}
@@ -3531,7 +3668,12 @@ function AutomationRow({
{history.slice(-4).map((record) => { {history.slice(-4).map((record) => {
const statusLabel = automationRunStatusLabel(record.status, tx); const statusLabel = automationRunStatusLabel(record.status, tx);
const duration = formatAutomationRunDuration(record.duration_ms, locale, tx); const duration = formatAutomationRunDuration(record.duration_ms, locale, tx);
const visibleLabel = record.status === "ok" ? duration : `${statusLabel} · ${duration}`; const visibleLabel = record.status === "ok"
? duration
: tx("settings.automations.history.statusWithDuration", "{{status}} · {{duration}}", {
status: statusLabel,
duration,
});
const detail = record.error || fmtDateTime(record.run_at_ms, locale); const detail = record.error || fmtDateTime(record.run_at_ms, locale);
const accessibleLabel = `${statusLabel} · ${duration} · ${detail}`; const accessibleLabel = `${statusLabel} · ${duration} · ${detail}`;
return ( return (
@@ -3555,11 +3697,35 @@ function AutomationRow({
</div> </div>
</div> </div>
) : null} ) : null}
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] leading-5 text-muted-foreground">
{job.created_at_ms ? (
<span>
{tx("settings.automations.meta.created", "Created {{time}}", {
time: fmtDateTime(job.created_at_ms, locale),
})}
</span>
) : null}
{job.updated_at_ms ? (
<span>
{tx("settings.automations.meta.updated", "Updated {{time}}", {
time: fmtDateTime(job.updated_at_ms, locale),
})}
</span>
) : null}
</div>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{canManage ? ( {canManage ? (
<> <>
<AppsActionButton
ariaLabel={tx("settings.automations.edit", "Edit")}
disabled={Boolean(actionKey)}
onClick={() => onRequestEdit(job)}
>
<Pencil className="h-4 w-4" aria-hidden />
</AppsActionButton>
<AppsActionButton <AppsActionButton
ariaLabel={tx("settings.automations.runNow", "Run now")} ariaLabel={tx("settings.automations.runNow", "Run now")}
busy={actionKey === `run:${job.id}`} busy={actionKey === `run:${job.id}`}
@@ -3603,17 +3769,252 @@ function AutomationRow({
); );
} }
function AutomationDetail({ label, children }: { label: string; children: ReactNode }) { function AutomationDetail({
label,
title,
children,
}: {
label: string;
title?: string;
children: ReactNode;
}) {
return ( return (
<div className="min-w-0 rounded-[14px] bg-muted/35 px-3 py-2"> <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"> <div className="text-[10.5px] font-medium uppercase leading-none text-muted-foreground/75">
{label} {label}
</div> </div>
<div className="mt-1.5 truncate text-[12.5px] leading-5 text-foreground/85">{children}</div> <div className="mt-1.5 truncate text-[12.5px] leading-5 text-foreground/85" title={title}>
{children}
</div>
</div> </div>
); );
} }
type AutomationEveryUnit = "second" | "minute" | "hour" | "day";
type AutomationEditDraft = {
name: string;
message: string;
scheduleKind: "at" | "every" | "cron";
everyValue: string;
everyUnit: AutomationEveryUnit;
cronExpr: string;
tz: string;
atLocal: string;
};
const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [
{ value: "second", ms: 1000 },
{ value: "minute", ms: 60_000 },
{ value: "hour", ms: 3_600_000 },
{ value: "day", ms: 86_400_000 },
];
function AutomationEditDialog({
job,
saving,
onOpenChange,
onSave,
}: {
job: SessionAutomationJob | null;
saving: boolean;
onOpenChange: (open: boolean) => void;
onSave: (job: SessionAutomationJob, values: AutomationUpdatePayload) => void | Promise<void>;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const [draft, setDraft] = useState<AutomationEditDraft>(() => automationDraftFromJob(null));
useEffect(() => {
setDraft(automationDraftFromJob(job));
}, [job]);
const validation = automationEditDraftError(draft, tx);
const scheduleOptions = [
{ value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") },
{ value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") },
{ value: "at", label: tx("settings.automations.scheduleTypes.at", "Once") },
];
const unitLabels: Record<AutomationEveryUnit, string> = {
second: tx("settings.automations.everyUnits.second", "Seconds"),
minute: tx("settings.automations.everyUnits.minute", "Minutes"),
hour: tx("settings.automations.everyUnits.hour", "Hours"),
day: tx("settings.automations.everyUnits.day", "Days"),
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const payload = automationUpdatePayloadFromDraft(draft);
if (!job || typeof payload === "string") return;
void onSave(job, payload);
};
return (
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
{job ? (
<DialogContent className="w-[min(calc(100vw-2rem),34rem)] rounded-[26px]">
<form className="space-y-5" onSubmit={submit}>
<DialogHeader>
<DialogTitle>{tx("settings.automations.editTitle", "Edit automation")}</DialogTitle>
<DialogDescription>
{tx(
"settings.automations.editDescription",
"Update the prompt and schedule. The linked chat stays unchanged.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.name", "Name")}
</span>
<Input
value={draft.name}
onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))}
className="h-10 rounded-[12px]"
/>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.message", "Message")}
</span>
<Textarea
value={draft.message}
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
className="min-h-24 resize-y rounded-[12px] text-[13px] leading-5"
/>
</label>
<div className="space-y-2">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.scheduleType", "Schedule type")}
</span>
<SegmentedControl
value={draft.scheduleKind}
options={scheduleOptions}
onChange={(value) =>
setDraft((prev) => ({
...prev,
scheduleKind: value as AutomationEditDraft["scheduleKind"],
}))
}
/>
</div>
{draft.scheduleKind === "every" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_10rem]">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.every", "Every")}
</span>
<Input
type="number"
min={1}
step={1}
value={draft.everyValue}
onChange={(event) =>
setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
}
className="h-10 rounded-[12px]"
/>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.unit", "Unit")}
</span>
<select
value={draft.everyUnit}
onChange={(event) =>
setDraft((prev) => ({
...prev,
everyUnit: event.target.value as AutomationEveryUnit,
}))
}
className="h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
{AUTOMATION_EVERY_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unitLabels[unit.value]}
</option>
))}
</select>
</label>
</div>
) : null}
{draft.scheduleKind === "cron" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.cronExpression", "Cron expression")}
</span>
<Input
value={draft.cronExpr}
onChange={(event) => setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
placeholder="0 9 * * *"
className="h-10 rounded-[12px] font-mono text-[13px]"
/>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.timezone", "Timezone")}
</span>
<Input
value={draft.tz}
onChange={(event) => setDraft((prev) => ({ ...prev, tz: event.target.value }))}
placeholder="Asia/Shanghai"
className="h-10 rounded-[12px] text-[13px]"
/>
</label>
</div>
) : null}
{draft.scheduleKind === "at" ? (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.runAt", "Run at")}
</span>
<Input
type="datetime-local"
value={draft.atLocal}
onChange={(event) => setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
className="h-10 rounded-[12px]"
/>
</label>
) : null}
{validation ? (
<div className="rounded-[12px] bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
{validation}
</div>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={saving}
className="rounded-full"
>
{tx("settings.automations.cancel", "Cancel")}
</Button>
<Button type="submit" disabled={Boolean(validation) || saving} className="rounded-full">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.automations.save", "Save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
) : null}
</Dialog>
);
}
function AutomationDeleteDialog({ function AutomationDeleteDialog({
job, job,
deleting, deleting,
@@ -3667,6 +4068,130 @@ function AutomationDeleteDialog({
); );
} }
function automationNeedsRecreation(job: SessionAutomationJob): boolean {
return !job.protected && !job.origin && job.payload.kind === "agent_turn";
}
function automationNeedsAttention(job: SessionAutomationJob): boolean {
return automationNeedsRecreation(job) || job.state.last_status === "error";
}
function automationStatusKey(
job: SessionAutomationJob,
): "active" | "running" | "paused" | "failed" | "system" | "needs_setup" | "completed" | "idle" {
if (job.protected) return "system";
if (automationNeedsRecreation(job)) return "needs_setup";
if (job.state.pending) return "running";
if (!job.enabled) return "paused";
if (job.state.last_status === "error") return "failed";
if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
return "completed";
}
if (!job.state.next_run_at_ms) return "idle";
return "active";
}
function sortAutomationJobs(jobs: SessionAutomationJob[], sort: AutomationSort): SessionAutomationJob[] {
const byName = (left: SessionAutomationJob, right: SessionAutomationJob) =>
(left.name || left.id).localeCompare(right.name || right.id);
return [...jobs].sort((left, right) => {
if (sort === "name") return byName(left, right);
if (sort === "last") {
return (right.state.last_run_at_ms ?? 0) - (left.state.last_run_at_ms ?? 0) || byName(left, right);
}
if (sort === "updated") {
return (right.updated_at_ms ?? 0) - (left.updated_at_ms ?? 0) || byName(left, right);
}
const leftNext = left.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
const rightNext = right.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
return leftNext - rightNext || byName(left, right);
});
}
function automationDraftFromJob(job: SessionAutomationJob | null): AutomationEditDraft {
const every = automationIntervalDraft(job?.schedule.every_ms ?? 3_600_000);
const scheduleKind = job?.schedule.kind === "at" || job?.schedule.kind === "cron"
? job.schedule.kind
: "every";
return {
name: job?.name ?? "",
message: job?.payload.message ?? "",
scheduleKind,
everyValue: every.value,
everyUnit: every.unit,
cronExpr: job?.schedule.expr ?? "0 9 * * *",
tz: job?.schedule.tz ?? "",
atLocal: formatLocalDateTimeInput(job?.schedule.at_ms ?? Date.now() + 3_600_000),
};
}
function automationIntervalDraft(ms: number): { value: string; unit: AutomationEveryUnit } {
for (const unit of [...AUTOMATION_EVERY_UNITS].reverse()) {
if (ms >= unit.ms && ms % unit.ms === 0) {
return { value: String(ms / unit.ms), unit: unit.value };
}
}
return { value: String(Math.max(1, Math.round(ms / 60_000))), unit: "minute" };
}
function formatLocalDateTimeInput(ms: number): string {
const date = new Date(ms);
if (!Number.isFinite(date.getTime())) return "";
const local = new Date(ms - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function automationEditDraftError(
draft: AutomationEditDraft,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string | null {
if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
if (!draft.message.trim()) {
return tx("settings.automations.validation.messageRequired", "Message is required.");
}
if (draft.scheduleKind === "every") {
const value = Number(draft.everyValue);
if (!Number.isInteger(value) || value <= 0) {
return tx("settings.automations.validation.intervalRequired", "Interval must be a positive number.");
}
}
if (draft.scheduleKind === "cron" && !draft.cronExpr.trim()) {
return tx("settings.automations.validation.cronRequired", "Cron expression is required.");
}
if (draft.scheduleKind === "at") {
const atMs = new Date(draft.atLocal).getTime();
if (!Number.isFinite(atMs)) {
return tx("settings.automations.validation.timeRequired", "Run time is required.");
}
if (atMs <= Date.now()) {
return tx("settings.automations.validation.futureRequired", "Run time must be in the future.");
}
}
return null;
}
function automationUpdatePayloadFromDraft(draft: AutomationEditDraft): AutomationUpdatePayload | string {
const name = draft.name.trim();
const message = draft.message.trim();
if (!name || !message) return "invalid";
const payload: AutomationUpdatePayload = { name, message };
if (draft.scheduleKind === "every") {
const unit = AUTOMATION_EVERY_UNITS.find((candidate) => candidate.value === draft.everyUnit);
const value = Number(draft.everyValue);
if (!unit || !Number.isInteger(value) || value <= 0) return "invalid";
payload.schedule = { kind: "every", every_ms: value * unit.ms };
} else if (draft.scheduleKind === "cron") {
const expr = draft.cronExpr.trim();
if (!expr) return "invalid";
payload.schedule = { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
} else {
const atMs = new Date(draft.atLocal).getTime();
if (!Number.isFinite(atMs)) return "invalid";
payload.schedule = { kind: "at", at_ms: atMs };
}
return payload;
}
function automationSearchText(job: SessionAutomationJob): string { function automationSearchText(job: SessionAutomationJob): string {
const originText = job.origin const originText = job.origin
? job.origin.channel === "websocket" ? job.origin.channel === "websocket"
@@ -3688,9 +4213,10 @@ function automationSearchText(job: SessionAutomationJob): string {
} }
function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean { function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
if (filter === "active") return job.enabled && !job.protected; const status = automationStatusKey(job);
if (filter === "paused") return !job.enabled && !job.protected; if (filter === "active") return status === "active" || status === "running";
if (filter === "failed") return job.state.last_status === "error"; if (filter === "paused") return status === "paused";
if (filter === "failed") return automationNeedsAttention(job);
if (filter === "system") return Boolean(job.protected); if (filter === "system") return Boolean(job.protected);
return true; return true;
} }
@@ -3699,12 +4225,24 @@ function automationStatus(
job: SessionAutomationJob, job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string, tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): { label: string; tone: "neutral" | "success" | "warning" } { ): { label: string; tone: "neutral" | "success" | "warning" } {
if (job.protected) return { label: tx("settings.automations.status.system", "System"), tone: "neutral" }; const status = automationStatusKey(job);
if (job.state.pending) return { label: tx("settings.automations.status.pending", "Pending"), tone: "warning" }; if (status === "system") return { label: tx("settings.automations.status.system", "System"), tone: "neutral" };
if (!job.enabled) return { label: tx("settings.automations.status.paused", "Paused"), tone: "neutral" }; if (status === "needs_setup") {
if (job.state.last_status === "error") { return { label: tx("settings.automations.status.needsSetup", "Needs setup"), tone: "warning" };
}
if (status === "running") {
return { label: tx("settings.automations.status.running", "Running now"), tone: "warning" };
}
if (status === "paused") return { label: tx("settings.automations.status.paused", "Paused"), tone: "neutral" };
if (status === "failed") {
return { label: tx("settings.automations.status.failed", "Failed"), tone: "warning" }; return { label: tx("settings.automations.status.failed", "Failed"), tone: "warning" };
} }
if (status === "completed") {
return { label: tx("settings.automations.status.completed", "Completed"), tone: "neutral" };
}
if (status === "idle") {
return { label: tx("settings.automations.status.noSchedule", "No schedule"), tone: "neutral" };
}
return { label: tx("settings.automations.status.active", "Active"), tone: "success" }; return { label: tx("settings.automations.status.active", "Active"), tone: "success" };
} }
@@ -3765,6 +4303,15 @@ function formatAutomationSchedule(
}); });
} }
if (job.schedule.kind === "cron" && job.schedule.expr) { if (job.schedule.kind === "cron" && job.schedule.expr) {
const summary = formatCronScheduleSummary(job.schedule.expr, tx);
if (summary) {
return job.schedule.tz
? tx("settings.automations.schedule.withTz", "{{summary}} · {{tz}}", {
summary,
tz: job.schedule.tz,
})
: summary;
}
return job.schedule.tz return job.schedule.tz
? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", { ? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", {
expr: job.schedule.expr, expr: job.schedule.expr,
@@ -3775,16 +4322,70 @@ function formatAutomationSchedule(
return tx("settings.automations.schedule.custom", "Custom schedule"); return tx("settings.automations.schedule.custom", "Custom schedule");
} }
function formatCronScheduleSummary(
expr: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
const numericMinute = cronNumericToken(minute, 59);
const numericHour = cronNumericToken(hour, 23);
const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
const workdays = dayOfMonth === "*" && month === "*" && ["1-5", "MON-FRI", "mon-fri"].includes(dayOfWeek);
if (numericMinute !== null && numericHour !== null) {
const time = `${String(numericHour).padStart(2, "0")}:${String(numericMinute).padStart(2, "0")}`;
if (everyDay) return tx("settings.automations.schedule.dailyAt", "Daily at {{time}}", { time });
if (workdays) return tx("settings.automations.schedule.weekdaysAt", "Weekdays at {{time}}", { time });
}
if (everyDay && numericMinute !== null && hour === "*") {
return tx("settings.automations.schedule.hourlyAt", "Hourly at :{{minute}}", {
minute: String(numericMinute).padStart(2, "0"),
});
}
const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
if (everyDay && numericMinute !== null && range) {
const start = Number(range[1]);
const end = Number(range[2]);
if (start > 23 || end > 23) return null;
return tx("settings.automations.schedule.hourlyWindow", "Hourly {{start}}-{{end}} at :{{minute}}", {
start: String(start).padStart(2, "0"),
end: String(end).padStart(2, "0"),
minute: String(numericMinute).padStart(2, "0"),
});
}
return null;
}
function cronNumericToken(value: string, max: number): number | null {
if (!/^\d{1,2}$/.test(value)) return null;
const parsed = Number(value);
return parsed <= max ? parsed : null;
}
function formatAutomationNext( function formatAutomationNext(
job: SessionAutomationJob, job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string, tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string { ): string {
if (!job.enabled) return tx("settings.automations.next.paused", "Paused"); 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.pending) return tx("settings.automations.next.pending", "Running now");
if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run"); if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
return relativeTime(job.state.next_run_at_ms); return relativeTime(job.state.next_run_at_ms);
} }
function formatAutomationNextTitle(
job: SessionAutomationJob,
locale: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (!job.state.next_run_at_ms) return formatAutomationNext(job, tx);
return fmtDateTime(job.state.next_run_at_ms, locale);
}
function formatAutomationLast( function formatAutomationLast(
job: SessionAutomationJob, job: SessionAutomationJob,
locale: string, locale: string,
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "Active", "active": "Active",
"paused": "Paused", "paused": "Paused",
"failed": "Failed", "failed": "Needs attention",
"system": "System" "system": "System"
}, },
"filters": { "filters": {
"all": "All", "all": "All",
"active": "Active", "active": "Active",
"paused": "Paused", "paused": "Paused",
"failed": "Failed", "failed": "Needs attention",
"system": "System" "system": "System"
}, },
"sort": {
"next": "Next run",
"last": "Last run",
"updated": "Updated",
"name": "Name"
},
"search": "Search automation, message, session, or cron expression", "search": "Search automation, message, session, or cron expression",
"queue": "Queue", "queue": "Queue",
"loading": "Loading automations...", "loading": "Loading automations...",
"noMatches": "No automations match this view.", "noMatches": "No automations match this view.",
"empty": "No automations yet.", "empty": "No automations yet.",
"emptyHint": "Create one from the chat or channel where it should run so nanobot keeps the right context.",
"oneShot": "One-time", "oneShot": "One-time",
"systemTask": "System-managed automation", "systemTask": "System-managed automation",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "Run now", "runNow": "Run now",
"pause": "Pause", "pause": "Pause",
"resume": "Resume", "resume": "Resume",
"edit": "Edit",
"delete": "Delete", "delete": "Delete",
"protected": "Protected", "protected": "Protected",
"editTitle": "Edit automation",
"editDescription": "Update the prompt and schedule. The linked chat stays unchanged.",
"save": "Save",
"deleteTitle": "Delete automation", "deleteTitle": "Delete automation",
"deleteDescription": "This removes {{name}} from the cron store. Past chat messages stay in the session.", "deleteDescription": "This removes {{name}} from the cron store. Past chat messages stay in the session.",
"cancel": "Cancel", "cancel": "Cancel",
"status": { "status": {
"system": "System", "system": "System",
"pending": "Pending", "pending": "Pending",
"running": "Running now",
"needsSetup": "Needs setup",
"paused": "Paused", "paused": "Paused",
"failed": "Failed", "failed": "Failed",
"completed": "Completed",
"noSchedule": "No schedule",
"active": "Active" "active": "Active"
}, },
"legacyWarning": "This older automation is missing its target chat. Recreate it from the chat or channel where it should run.",
"origin": { "origin": {
"system": "System", "system": "System",
"unknown": "No linked chat", "unknown": "No linked chat",
@@ -534,11 +550,16 @@
"every": "Every {{duration}}", "every": "Every {{duration}}",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Daily at {{time}}",
"weekdaysAt": "Weekdays at {{time}}",
"hourlyAt": "Hourly at :{{minute}}",
"hourlyWindow": "Hourly {{start}}-{{end}} at :{{minute}}",
"custom": "Custom schedule" "custom": "Custom schedule"
}, },
"next": { "next": {
"paused": "Paused", "paused": "Paused",
"pending": "Running soon", "pending": "Running now",
"none": "No next run" "none": "No next run"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "Completed", "ok": "Completed",
"error": "Error", "error": "Error",
"skipped": "Skipped", "skipped": "Skipped",
"recent": "Recent runs" "recent": "Recent runs",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "Created {{time}}",
"updated": "Updated {{time}}"
},
"fields": {
"name": "Name",
"message": "Message",
"scheduleType": "Schedule type",
"every": "Every",
"unit": "Unit",
"cronExpression": "Cron expression",
"timezone": "Timezone",
"runAt": "Run at"
},
"scheduleTypes": {
"every": "Interval",
"cron": "Cron",
"at": "Once"
},
"everyUnits": {
"second": "Seconds",
"minute": "Minutes",
"hour": "Hours",
"day": "Days"
},
"validation": {
"nameRequired": "Name is required.",
"messageRequired": "Message is required.",
"intervalRequired": "Interval must be a positive number.",
"cronRequired": "Cron expression is required.",
"timeRequired": "Run time is required.",
"futureRequired": "Run time must be in the future."
}, },
"duration": { "duration": {
"lessThanSecond": "< 1 second" "lessThanSecond": "< 1 second"
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "Activas", "active": "Activas",
"paused": "Pausadas", "paused": "Pausadas",
"failed": "Fallidas", "failed": "Requieren atención",
"system": "Sistema" "system": "Sistema"
}, },
"filters": { "filters": {
"all": "Todas", "all": "Todas",
"active": "Activas", "active": "Activas",
"paused": "Pausadas", "paused": "Pausadas",
"failed": "Fallidas", "failed": "Requieren atención",
"system": "Sistema" "system": "Sistema"
}, },
"sort": {
"next": "Próxima ejecución",
"last": "Última ejecución",
"updated": "Actualizada",
"name": "Nombre"
},
"search": "Buscar tarea, mensaje, sesión o expresión cron", "search": "Buscar tarea, mensaje, sesión o expresión cron",
"queue": "Cola", "queue": "Cola",
"loading": "Cargando automatizaciones...", "loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.", "noMatches": "No hay automatizaciones que coincidan con esta vista.",
"empty": "Aún no hay automatizaciones.", "empty": "Aún no hay automatizaciones.",
"emptyHint": "Crea una desde el chat o canal donde debe ejecutarse para que nanobot conserve el contexto correcto.",
"oneShot": "Una vez", "oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema", "systemTask": "Automatización administrada por el sistema",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "Ejecutar ahora", "runNow": "Ejecutar ahora",
"pause": "Pausar", "pause": "Pausar",
"resume": "Reanudar", "resume": "Reanudar",
"edit": "Editar",
"delete": "Eliminar", "delete": "Eliminar",
"protected": "Protegida", "protected": "Protegida",
"editTitle": "Editar automatización",
"editDescription": "Actualiza el prompt y la programación. El chat vinculado no cambia.",
"save": "Guardar",
"deleteTitle": "Eliminar automatización", "deleteTitle": "Eliminar automatización",
"deleteDescription": "Esto elimina {{name}} del almacén cron. Los mensajes de chat anteriores permanecen en la sesión.", "deleteDescription": "Esto elimina {{name}} del almacén cron. Los mensajes de chat anteriores permanecen en la sesión.",
"cancel": "Cancelar", "cancel": "Cancelar",
"status": { "status": {
"system": "Sistema", "system": "Sistema",
"pending": "Pendiente", "pending": "Pendiente",
"running": "Ejecutándose ahora",
"needsSetup": "Requiere configuración",
"paused": "Pausada", "paused": "Pausada",
"failed": "Fallida", "failed": "Fallida",
"completed": "Completada",
"noSchedule": "Sin programación",
"active": "Activa" "active": "Activa"
}, },
"legacyWarning": "Esta automatización antigua no tiene chat de destino. Vuelve a crearla desde el chat o canal donde debe ejecutarse.",
"origin": { "origin": {
"system": "Sistema", "system": "Sistema",
"unknown": "Sin chat vinculado", "unknown": "Sin chat vinculado",
@@ -534,11 +550,16 @@
"every": "Cada {{duration}}", "every": "Cada {{duration}}",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Diaria a las {{time}}",
"weekdaysAt": "Días laborables a las {{time}}",
"hourlyAt": "Cada hora en :{{minute}}",
"hourlyWindow": "Cada hora {{start}}-{{end}} en :{{minute}}",
"custom": "Programación personalizada" "custom": "Programación personalizada"
}, },
"next": { "next": {
"paused": "Pausada", "paused": "Pausada",
"pending": "Se ejecutará pronto", "pending": "Ejecutándose ahora",
"none": "Sin próxima ejecución" "none": "Sin próxima ejecución"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "Completada", "ok": "Completada",
"error": "Error", "error": "Error",
"skipped": "Omitida", "skipped": "Omitida",
"recent": "Ejecuciones recientes" "recent": "Ejecuciones recientes",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "Creada {{time}}",
"updated": "Actualizada {{time}}"
},
"fields": {
"name": "Nombre",
"message": "Mensaje",
"scheduleType": "Tipo de programación",
"every": "Cada",
"unit": "Unidad",
"cronExpression": "Expresión cron",
"timezone": "Zona horaria",
"runAt": "Ejecutar a las"
},
"scheduleTypes": {
"every": "Intervalo",
"cron": "Cron",
"at": "Una vez"
},
"everyUnits": {
"second": "Segundos",
"minute": "Minutos",
"hour": "Horas",
"day": "Días"
},
"validation": {
"nameRequired": "El nombre es obligatorio.",
"messageRequired": "El mensaje es obligatorio.",
"intervalRequired": "El intervalo debe ser un número positivo.",
"cronRequired": "La expresión cron es obligatoria.",
"timeRequired": "La hora de ejecución es obligatoria.",
"futureRequired": "La hora de ejecución debe estar en el futuro."
}, },
"duration": { "duration": {
"lessThanSecond": "menos de 1 segundo" "lessThanSecond": "menos de 1 segundo"
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "Actives", "active": "Actives",
"paused": "En pause", "paused": "En pause",
"failed": "Échouées", "failed": "À traiter",
"system": "Système" "system": "Système"
}, },
"filters": { "filters": {
"all": "Toutes", "all": "Toutes",
"active": "Actives", "active": "Actives",
"paused": "En pause", "paused": "En pause",
"failed": "Échouées", "failed": "À traiter",
"system": "Système" "system": "Système"
}, },
"sort": {
"next": "Prochaine exécution",
"last": "Dernière exécution",
"updated": "Mise à jour",
"name": "Nom"
},
"search": "Rechercher une tâche, un message, une session ou une expression cron", "search": "Rechercher une tâche, un message, une session ou une expression cron",
"queue": "File", "queue": "File",
"loading": "Chargement des automatisations...", "loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.", "noMatches": "Aucune automatisation ne correspond à cette vue.",
"empty": "Aucune automatisation pour le moment.", "empty": "Aucune automatisation pour le moment.",
"emptyHint": "Créez-en une depuis la discussion ou le canal où elle doit sexécuter afin que nanobot conserve le bon contexte.",
"oneShot": "Ponctuelle", "oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système", "systemTask": "Automatisation gérée par le système",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "Exécuter maintenant", "runNow": "Exécuter maintenant",
"pause": "Mettre en pause", "pause": "Mettre en pause",
"resume": "Reprendre", "resume": "Reprendre",
"edit": "Modifier",
"delete": "Supprimer", "delete": "Supprimer",
"protected": "Protégée", "protected": "Protégée",
"editTitle": "Modifier lautomatisation",
"editDescription": "Modifiez le prompt et le planning. La discussion liée ne change pas.",
"save": "Enregistrer",
"deleteTitle": "Supprimer lautomatisation", "deleteTitle": "Supprimer lautomatisation",
"deleteDescription": "Cela supprime {{name}} du stockage cron. Les anciens messages de chat restent dans la session.", "deleteDescription": "Cela supprime {{name}} du stockage cron. Les anciens messages de chat restent dans la session.",
"cancel": "Annuler", "cancel": "Annuler",
"status": { "status": {
"system": "Système", "system": "Système",
"pending": "En attente", "pending": "En attente",
"running": "En cours dexécution",
"needsSetup": "Configuration requise",
"paused": "En pause", "paused": "En pause",
"failed": "Échouée", "failed": "Échouée",
"completed": "Terminée",
"noSchedule": "Aucun planning",
"active": "En cours" "active": "En cours"
}, },
"legacyWarning": "Cette ancienne automatisation na pas de discussion cible. Recréez-la depuis la discussion ou le canal où elle doit sexécuter.",
"origin": { "origin": {
"system": "Système", "system": "Système",
"unknown": "Aucune discussion liée", "unknown": "Aucune discussion liée",
@@ -534,11 +550,16 @@
"every": "Toutes les {{duration}}", "every": "Toutes les {{duration}}",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Chaque jour à {{time}}",
"weekdaysAt": "Jours ouvrés à {{time}}",
"hourlyAt": "Toutes les heures à :{{minute}}",
"hourlyWindow": "Toutes les heures {{start}}-{{end}} à :{{minute}}",
"custom": "Planning personnalisé" "custom": "Planning personnalisé"
}, },
"next": { "next": {
"paused": "En pause", "paused": "En pause",
"pending": "Exécution prochaine", "pending": "En cours dexécution",
"none": "Aucune prochaine exécution" "none": "Aucune prochaine exécution"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "Terminée", "ok": "Terminée",
"error": "Erreur", "error": "Erreur",
"skipped": "Ignorée", "skipped": "Ignorée",
"recent": "Exécutions récentes" "recent": "Exécutions récentes",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "Créée {{time}}",
"updated": "Mise à jour {{time}}"
},
"fields": {
"name": "Nom",
"message": "Message",
"scheduleType": "Type de planning",
"every": "Toutes les",
"unit": "Unité",
"cronExpression": "Expression cron",
"timezone": "Fuseau horaire",
"runAt": "Exécuter à"
},
"scheduleTypes": {
"every": "Intervalle",
"cron": "Cron",
"at": "Une fois"
},
"everyUnits": {
"second": "Secondes",
"minute": "Minutes",
"hour": "Heures",
"day": "Jours"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"messageRequired": "Le message est obligatoire.",
"intervalRequired": "Lintervalle doit être un nombre positif.",
"cronRequired": "Lexpression cron est obligatoire.",
"timeRequired": "Lheure dexécution est obligatoire.",
"futureRequired": "Lheure dexécution doit être dans le futur."
}, },
"duration": { "duration": {
"lessThanSecond": "moins de 1 seconde" "lessThanSecond": "moins de 1 seconde"
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "Aktif", "active": "Aktif",
"paused": "Dijeda", "paused": "Dijeda",
"failed": "Gagal", "failed": "Perlu ditangani",
"system": "Sistem" "system": "Sistem"
}, },
"filters": { "filters": {
"all": "Semua", "all": "Semua",
"active": "Aktif", "active": "Aktif",
"paused": "Dijeda", "paused": "Dijeda",
"failed": "Gagal", "failed": "Perlu ditangani",
"system": "Sistem" "system": "Sistem"
}, },
"sort": {
"next": "Jalankan berikutnya",
"last": "Jalankan terakhir",
"updated": "Diperbarui",
"name": "Nama"
},
"search": "Cari tugas, pesan, sesi, atau ekspresi cron", "search": "Cari tugas, pesan, sesi, atau ekspresi cron",
"queue": "Antrean", "queue": "Antrean",
"loading": "Memuat otomasi...", "loading": "Memuat otomasi...",
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.", "noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
"empty": "Belum ada otomasi.", "empty": "Belum ada otomasi.",
"emptyHint": "Buat dari chat atau channel tempat otomasi akan berjalan agar nanobot menyimpan konteks yang benar.",
"oneShot": "Satu kali", "oneShot": "Satu kali",
"systemTask": "Automasi yang dikelola sistem", "systemTask": "Automasi yang dikelola sistem",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "Jalankan sekarang", "runNow": "Jalankan sekarang",
"pause": "Jeda", "pause": "Jeda",
"resume": "Lanjutkan", "resume": "Lanjutkan",
"edit": "Edit",
"delete": "Hapus", "delete": "Hapus",
"protected": "Terlindungi", "protected": "Terlindungi",
"editTitle": "Edit otomasi",
"editDescription": "Perbarui prompt dan jadwal. Chat tertaut tidak berubah.",
"save": "Simpan",
"deleteTitle": "Hapus otomasi", "deleteTitle": "Hapus otomasi",
"deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.", "deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.",
"cancel": "Batal", "cancel": "Batal",
"status": { "status": {
"system": "Sistem", "system": "Sistem",
"pending": "Menunggu", "pending": "Menunggu",
"running": "Sedang berjalan",
"needsSetup": "Perlu disiapkan",
"paused": "Dijeda", "paused": "Dijeda",
"failed": "Gagal", "failed": "Gagal",
"completed": "Selesai",
"noSchedule": "Tanpa jadwal",
"active": "Aktif" "active": "Aktif"
}, },
"legacyWarning": "Otomasi lama ini tidak memiliki chat tujuan. Buat ulang dari chat atau channel tempat otomasi akan berjalan.",
"origin": { "origin": {
"system": "Sistem", "system": "Sistem",
"unknown": "Tidak ada chat tertaut", "unknown": "Tidak ada chat tertaut",
@@ -534,11 +550,16 @@
"every": "Setiap {{duration}}", "every": "Setiap {{duration}}",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Setiap hari pukul {{time}}",
"weekdaysAt": "Hari kerja pukul {{time}}",
"hourlyAt": "Setiap jam pada :{{minute}}",
"hourlyWindow": "Setiap jam {{start}}-{{end}} pada :{{minute}}",
"custom": "Jadwal khusus" "custom": "Jadwal khusus"
}, },
"next": { "next": {
"paused": "Dijeda", "paused": "Dijeda",
"pending": "Segera berjalan", "pending": "Sedang berjalan",
"none": "Tidak ada jadwal berikutnya" "none": "Tidak ada jadwal berikutnya"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "Selesai", "ok": "Selesai",
"error": "Error", "error": "Error",
"skipped": "Dilewati", "skipped": "Dilewati",
"recent": "Eksekusi terbaru" "recent": "Eksekusi terbaru",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "Dibuat {{time}}",
"updated": "Diperbarui {{time}}"
},
"fields": {
"name": "Nama",
"message": "Pesan",
"scheduleType": "Jenis jadwal",
"every": "Setiap",
"unit": "Unit",
"cronExpression": "Ekspresi cron",
"timezone": "Zona waktu",
"runAt": "Jalankan pada"
},
"scheduleTypes": {
"every": "Interval",
"cron": "Cron",
"at": "Sekali"
},
"everyUnits": {
"second": "Detik",
"minute": "Menit",
"hour": "Jam",
"day": "Hari"
},
"validation": {
"nameRequired": "Nama wajib diisi.",
"messageRequired": "Pesan wajib diisi.",
"intervalRequired": "Interval harus berupa angka positif.",
"cronRequired": "Ekspresi cron wajib diisi.",
"timeRequired": "Waktu eksekusi wajib diisi.",
"futureRequired": "Waktu eksekusi harus berada di masa depan."
}, },
"duration": { "duration": {
"lessThanSecond": "kurang dari 1 detik" "lessThanSecond": "kurang dari 1 detik"
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "実行中", "active": "実行中",
"paused": "一時停止", "paused": "一時停止",
"failed": "失敗", "failed": "要対応",
"system": "システム" "system": "システム"
}, },
"filters": { "filters": {
"all": "すべて", "all": "すべて",
"active": "実行中", "active": "実行中",
"paused": "一時停止", "paused": "一時停止",
"failed": "失敗", "failed": "要対応",
"system": "システム" "system": "システム"
}, },
"sort": {
"next": "次回実行",
"last": "前回実行",
"updated": "更新日時",
"name": "名前"
},
"search": "タスク、メッセージ、セッション、cron 式を検索", "search": "タスク、メッセージ、セッション、cron 式を検索",
"queue": "キュー", "queue": "キュー",
"loading": "自動タスクを読み込み中...", "loading": "自動タスクを読み込み中...",
"noMatches": "この表示に一致する自動タスクはありません。", "noMatches": "この表示に一致する自動タスクはありません。",
"empty": "自動タスクはまだありません。", "empty": "自動タスクはまだありません。",
"emptyHint": "実行先のチャットまたは外部 channel から作成すると、nanobot が正しいコンテキストを保持できます。",
"oneShot": "一回限り", "oneShot": "一回限り",
"systemTask": "システム管理の自動タスク", "systemTask": "システム管理の自動タスク",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "今すぐ実行", "runNow": "今すぐ実行",
"pause": "一時停止", "pause": "一時停止",
"resume": "再開", "resume": "再開",
"edit": "編集",
"delete": "削除", "delete": "削除",
"protected": "保護済み", "protected": "保護済み",
"editTitle": "自動タスクを編集",
"editDescription": "プロンプトとスケジュールを更新します。関連チャットは変更されません。",
"save": "保存",
"deleteTitle": "自動タスクを削除", "deleteTitle": "自動タスクを削除",
"deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。", "deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。",
"cancel": "キャンセル", "cancel": "キャンセル",
"status": { "status": {
"system": "システム", "system": "システム",
"pending": "待機中", "pending": "待機中",
"running": "実行中",
"needsSetup": "再設定が必要",
"paused": "一時停止", "paused": "一時停止",
"failed": "失敗", "failed": "失敗",
"completed": "完了",
"noSchedule": "スケジュールなし",
"active": "実行中" "active": "実行中"
}, },
"legacyWarning": "この古い自動タスクには対象チャットがありません。実行先のチャットまたは外部 channel から作り直してください。",
"origin": { "origin": {
"system": "システム", "system": "システム",
"unknown": "関連チャットなし", "unknown": "関連チャットなし",
@@ -534,11 +550,16 @@
"every": "{{duration}} ごと", "every": "{{duration}} ごと",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "毎日 {{time}}",
"weekdaysAt": "平日 {{time}}",
"hourlyAt": "毎時 :{{minute}}",
"hourlyWindow": "{{start}}-{{end}} の毎時 :{{minute}}",
"custom": "カスタムスケジュール" "custom": "カスタムスケジュール"
}, },
"next": { "next": {
"paused": "一時停止", "paused": "一時停止",
"pending": "まもなく実行", "pending": "実行",
"none": "次回実行なし" "none": "次回実行なし"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "完了", "ok": "完了",
"error": "エラー", "error": "エラー",
"skipped": "スキップ", "skipped": "スキップ",
"recent": "最近の実行" "recent": "最近の実行",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "{{time}} 作成",
"updated": "{{time}} 更新"
},
"fields": {
"name": "名前",
"message": "メッセージ",
"scheduleType": "スケジュール種別",
"every": "間隔",
"unit": "単位",
"cronExpression": "Cron 式",
"timezone": "タイムゾーン",
"runAt": "実行日時"
},
"scheduleTypes": {
"every": "間隔",
"cron": "Cron",
"at": "一回限り"
},
"everyUnits": {
"second": "秒",
"minute": "分",
"hour": "時間",
"day": "日"
},
"validation": {
"nameRequired": "名前は必須です。",
"messageRequired": "メッセージは必須です。",
"intervalRequired": "間隔は正の数で指定してください。",
"cronRequired": "Cron 式は必須です。",
"timeRequired": "実行日時は必須です。",
"futureRequired": "実行日時は現在より後にしてください。"
}, },
"duration": { "duration": {
"lessThanSecond": "1 秒未満" "lessThanSecond": "1 秒未満"
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "활성", "active": "활성",
"paused": "일시 중지", "paused": "일시 중지",
"failed": "실패", "failed": "확인 필요",
"system": "시스템" "system": "시스템"
}, },
"filters": { "filters": {
"all": "전체", "all": "전체",
"active": "활성", "active": "활성",
"paused": "일시 중지", "paused": "일시 중지",
"failed": "실패", "failed": "확인 필요",
"system": "시스템" "system": "시스템"
}, },
"sort": {
"next": "다음 실행",
"last": "마지막 실행",
"updated": "업데이트",
"name": "이름"
},
"search": "작업, 메시지, 세션 또는 cron 식 검색", "search": "작업, 메시지, 세션 또는 cron 식 검색",
"queue": "대기열", "queue": "대기열",
"loading": "자동화를 불러오는 중...", "loading": "자동화를 불러오는 중...",
"noMatches": "이 보기와 일치하는 자동화가 없습니다.", "noMatches": "이 보기와 일치하는 자동화가 없습니다.",
"empty": "아직 자동화가 없습니다.", "empty": "아직 자동화가 없습니다.",
"emptyHint": "실행되어야 하는 채팅 또는 외부 channel에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
"oneShot": "일회성", "oneShot": "일회성",
"systemTask": "시스템 관리 자동화", "systemTask": "시스템 관리 자동화",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "지금 실행", "runNow": "지금 실행",
"pause": "일시 중지", "pause": "일시 중지",
"resume": "재개", "resume": "재개",
"edit": "편집",
"delete": "삭제", "delete": "삭제",
"protected": "보호됨", "protected": "보호됨",
"editTitle": "자동화 편집",
"editDescription": "프롬프트와 일정을 업데이트합니다. 연결된 채팅은 변경되지 않습니다.",
"save": "저장",
"deleteTitle": "자동화 삭제", "deleteTitle": "자동화 삭제",
"deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.", "deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.",
"cancel": "취소", "cancel": "취소",
"status": { "status": {
"system": "시스템", "system": "시스템",
"pending": "대기 중", "pending": "대기 중",
"running": "실행 중",
"needsSetup": "설정 필요",
"paused": "일시 중지", "paused": "일시 중지",
"failed": "실패", "failed": "실패",
"completed": "완료",
"noSchedule": "일정 없음",
"active": "활성" "active": "활성"
}, },
"legacyWarning": "이전 자동화에 대상 채팅이 없습니다. 실행되어야 하는 채팅 또는 외부 channel에서 다시 만드세요.",
"origin": { "origin": {
"system": "시스템", "system": "시스템",
"unknown": "연결된 채팅 없음", "unknown": "연결된 채팅 없음",
@@ -534,11 +550,16 @@
"every": "{{duration}}마다", "every": "{{duration}}마다",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "매일 {{time}}",
"weekdaysAt": "평일 {{time}}",
"hourlyAt": "매시간 :{{minute}}",
"hourlyWindow": "{{start}}-{{end}} 사이 매시간 :{{minute}}",
"custom": "사용자 지정 일정" "custom": "사용자 지정 일정"
}, },
"next": { "next": {
"paused": "일시 중지", "paused": "일시 중지",
"pending": "실행", "pending": "실행",
"none": "다음 실행 없음" "none": "다음 실행 없음"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "완료", "ok": "완료",
"error": "오류", "error": "오류",
"skipped": "건너뜀", "skipped": "건너뜀",
"recent": "최근 실행" "recent": "최근 실행",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "{{time}} 생성",
"updated": "{{time}} 업데이트"
},
"fields": {
"name": "이름",
"message": "메시지",
"scheduleType": "일정 유형",
"every": "간격",
"unit": "단위",
"cronExpression": "Cron 식",
"timezone": "시간대",
"runAt": "실행 시간"
},
"scheduleTypes": {
"every": "간격",
"cron": "Cron",
"at": "일회성"
},
"everyUnits": {
"second": "초",
"minute": "분",
"hour": "시간",
"day": "일"
},
"validation": {
"nameRequired": "이름은 필수입니다.",
"messageRequired": "메시지는 필수입니다.",
"intervalRequired": "간격은 양수여야 합니다.",
"cronRequired": "Cron 식은 필수입니다.",
"timeRequired": "실행 시간은 필수입니다.",
"futureRequired": "실행 시간은 현재보다 이후여야 합니다."
}, },
"duration": { "duration": {
"lessThanSecond": "1초 미만" "lessThanSecond": "1초 미만"
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "Đang chạy", "active": "Đang chạy",
"paused": "Đã tạm dừng", "paused": "Đã tạm dừng",
"failed": "Thất bại", "failed": "Cần xử lý",
"system": "Hệ thống" "system": "Hệ thống"
}, },
"filters": { "filters": {
"all": "Tất cả", "all": "Tất cả",
"active": "Đang chạy", "active": "Đang chạy",
"paused": "Đã tạm dừng", "paused": "Đã tạm dừng",
"failed": "Thất bại", "failed": "Cần xử lý",
"system": "Hệ thống" "system": "Hệ thống"
}, },
"sort": {
"next": "Lần chạy tiếp theo",
"last": "Lần chạy trước",
"updated": "Đã cập nhật",
"name": "Tên"
},
"search": "Tìm tác vụ, tin nhắn, phiên hoặc biểu thức cron", "search": "Tìm tác vụ, tin nhắn, phiên hoặc biểu thức cron",
"queue": "Hàng đợi", "queue": "Hàng đợi",
"loading": "Đang tải tự động hóa...", "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.", "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.", "empty": "Chưa có tự động hóa.",
"emptyHint": "Tạo từ cuộc trò chuyện hoặc channel nơi tự động hóa sẽ chạy để nanobot giữ đúng ngữ cảnh.",
"oneShot": "Một lần", "oneShot": "Một lần",
"systemTask": "Tự động hóa do hệ thống quản lý", "systemTask": "Tự động hóa do hệ thống quản lý",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "Chạy ngay", "runNow": "Chạy ngay",
"pause": "Tạm dừng", "pause": "Tạm dừng",
"resume": "Tiếp tục", "resume": "Tiếp tục",
"edit": "Sửa",
"delete": "Xóa", "delete": "Xóa",
"protected": "Được bảo vệ", "protected": "Được bảo vệ",
"editTitle": "Sửa tự động hóa",
"editDescription": "Cập nhật prompt và lịch. Cuộc trò chuyện liên kết không thay đổi.",
"save": "Lưu",
"deleteTitle": "Xóa tự động hóa", "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.", "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", "cancel": "Hủy",
"status": { "status": {
"system": "Hệ thống", "system": "Hệ thống",
"pending": "Đang chờ", "pending": "Đang chờ",
"running": "Đang chạy",
"needsSetup": "Cần thiết lập",
"paused": "Đã tạm dừng", "paused": "Đã tạm dừng",
"failed": "Thất bại", "failed": "Thất bại",
"completed": "Hoàn tất",
"noSchedule": "Không có lịch",
"active": "Đang chạy" "active": "Đang chạy"
}, },
"legacyWarning": "Tự động hóa cũ này thiếu cuộc trò chuyện đích. Hãy tạo lại từ cuộc trò chuyện hoặc channel nơi nó sẽ chạy.",
"origin": { "origin": {
"system": "Hệ thống", "system": "Hệ thống",
"unknown": "Chưa liên kết cuộc trò chuyện", "unknown": "Chưa liên kết cuộc trò chuyện",
@@ -534,11 +550,16 @@
"every": "Mỗi {{duration}}", "every": "Mỗi {{duration}}",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "Hằng ngày lúc {{time}}",
"weekdaysAt": "Ngày làm việc lúc {{time}}",
"hourlyAt": "Mỗi giờ tại :{{minute}}",
"hourlyWindow": "Mỗi giờ {{start}}-{{end}} tại :{{minute}}",
"custom": "Lịch tùy chỉnh" "custom": "Lịch tùy chỉnh"
}, },
"next": { "next": {
"paused": "Đã tạm dừng", "paused": "Đã tạm dừng",
"pending": "Sắp chạy", "pending": "Đang chạy",
"none": "Không có lần chạy tiếp theo" "none": "Không có lần chạy tiếp theo"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "Hoàn tất", "ok": "Hoàn tất",
"error": "Lỗi", "error": "Lỗi",
"skipped": "Đã bỏ qua", "skipped": "Đã bỏ qua",
"recent": "Lượt chạy gần đây" "recent": "Lượt chạy gần đây",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "Tạo lúc {{time}}",
"updated": "Cập nhật lúc {{time}}"
},
"fields": {
"name": "Tên",
"message": "Tin nhắn",
"scheduleType": "Loại lịch",
"every": "Mỗi",
"unit": "Đơn vị",
"cronExpression": "Biểu thức cron",
"timezone": "Múi giờ",
"runAt": "Chạy lúc"
},
"scheduleTypes": {
"every": "Khoảng lặp",
"cron": "Cron",
"at": "Một lần"
},
"everyUnits": {
"second": "Giây",
"minute": "Phút",
"hour": "Giờ",
"day": "Ngày"
},
"validation": {
"nameRequired": "Tên là bắt buộc.",
"messageRequired": "Tin nhắn là bắt buộc.",
"intervalRequired": "Khoảng lặp phải là số dương.",
"cronRequired": "Biểu thức cron là bắt buộc.",
"timeRequired": "Thời gian chạy là bắt buộc.",
"futureRequired": "Thời gian chạy phải ở tương lai."
}, },
"duration": { "duration": {
"lessThanSecond": "dưới 1 giây" "lessThanSecond": "dưới 1 giây"
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "运行中", "active": "运行中",
"paused": "已暂停", "paused": "已暂停",
"failed": "失败", "failed": "需处理",
"system": "系统" "system": "系统"
}, },
"filters": { "filters": {
"all": "全部", "all": "全部",
"active": "运行中", "active": "运行中",
"paused": "已暂停", "paused": "已暂停",
"failed": "失败", "failed": "需处理",
"system": "系统" "system": "系统"
}, },
"sort": {
"next": "下次运行",
"last": "上次运行",
"updated": "更新时间",
"name": "名称"
},
"search": "搜索任务、消息、会话或 cron 表达式", "search": "搜索任务、消息、会话或 cron 表达式",
"queue": "任务队列", "queue": "任务队列",
"loading": "正在加载自动任务...", "loading": "正在加载自动任务...",
"noMatches": "当前视图没有匹配的自动任务。", "noMatches": "当前视图没有匹配的自动任务。",
"empty": "暂无自动任务。", "empty": "暂无自动任务。",
"emptyHint": "请从它应该运行的聊天或外部 channel 中创建,这样 nanobot 才能保留正确上下文。",
"oneShot": "一次性", "oneShot": "一次性",
"systemTask": "系统管理的自动任务", "systemTask": "系统管理的自动任务",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "立即运行", "runNow": "立即运行",
"pause": "暂停", "pause": "暂停",
"resume": "恢复", "resume": "恢复",
"edit": "编辑",
"delete": "删除", "delete": "删除",
"protected": "受保护", "protected": "受保护",
"editTitle": "编辑自动任务",
"editDescription": "更新提示词和计划;关联会话不会改变。",
"save": "保存",
"deleteTitle": "删除自动任务", "deleteTitle": "删除自动任务",
"deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。", "deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。",
"cancel": "取消", "cancel": "取消",
"status": { "status": {
"system": "系统", "system": "系统",
"pending": "等待中", "pending": "等待中",
"running": "正在运行",
"needsSetup": "需重新设置",
"paused": "已暂停", "paused": "已暂停",
"failed": "失败", "failed": "失败",
"completed": "已完成",
"noSchedule": "无计划",
"active": "运行中" "active": "运行中"
}, },
"legacyWarning": "这个旧版自动任务缺少目标会话。请从它应该运行的聊天或外部 channel 中重新创建。",
"origin": { "origin": {
"system": "系统", "system": "系统",
"unknown": "未关联会话", "unknown": "未关联会话",
@@ -534,11 +550,16 @@
"every": "每 {{duration}}", "every": "每 {{duration}}",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "每天 {{time}}",
"weekdaysAt": "工作日 {{time}}",
"hourlyAt": "每小时第 {{minute}} 分钟",
"hourlyWindow": "{{start}}-{{end}} 点每小时第 {{minute}} 分钟",
"custom": "自定义计划" "custom": "自定义计划"
}, },
"next": { "next": {
"paused": "已暂停", "paused": "已暂停",
"pending": "即将运行", "pending": "正在运行",
"none": "没有下次运行" "none": "没有下次运行"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "完成", "ok": "完成",
"error": "错误", "error": "错误",
"skipped": "已跳过", "skipped": "已跳过",
"recent": "最近运行" "recent": "最近运行",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "创建于 {{time}}",
"updated": "更新于 {{time}}"
},
"fields": {
"name": "名称",
"message": "消息",
"scheduleType": "计划类型",
"every": "每隔",
"unit": "单位",
"cronExpression": "Cron 表达式",
"timezone": "时区",
"runAt": "运行时间"
},
"scheduleTypes": {
"every": "间隔",
"cron": "Cron",
"at": "一次性"
},
"everyUnits": {
"second": "秒",
"minute": "分钟",
"hour": "小时",
"day": "天"
},
"validation": {
"nameRequired": "名称不能为空。",
"messageRequired": "消息不能为空。",
"intervalRequired": "间隔必须是正整数。",
"cronRequired": "Cron 表达式不能为空。",
"timeRequired": "运行时间不能为空。",
"futureRequired": "运行时间必须晚于当前时间。"
}, },
"duration": { "duration": {
"lessThanSecond": "不到 1 秒" "lessThanSecond": "不到 1 秒"
+59 -4
View File
@@ -469,21 +469,28 @@
"stats": { "stats": {
"active": "執行中", "active": "執行中",
"paused": "已暫停", "paused": "已暫停",
"failed": "失敗", "failed": "需處理",
"system": "系統" "system": "系統"
}, },
"filters": { "filters": {
"all": "全部", "all": "全部",
"active": "執行中", "active": "執行中",
"paused": "已暫停", "paused": "已暫停",
"failed": "失敗", "failed": "需處理",
"system": "系統" "system": "系統"
}, },
"sort": {
"next": "下次執行",
"last": "上次執行",
"updated": "更新時間",
"name": "名稱"
},
"search": "搜尋任務、訊息、會話或 cron 表達式", "search": "搜尋任務、訊息、會話或 cron 表達式",
"queue": "任務佇列", "queue": "任務佇列",
"loading": "正在載入自動任務...", "loading": "正在載入自動任務...",
"noMatches": "目前檢視沒有符合的自動任務。", "noMatches": "目前檢視沒有符合的自動任務。",
"empty": "尚無自動任務。", "empty": "尚無自動任務。",
"emptyHint": "請從它應該執行的聊天或外部 channel 中建立,這樣 nanobot 才能保留正確上下文。",
"oneShot": "一次性", "oneShot": "一次性",
"systemTask": "系統管理的自動任務", "systemTask": "系統管理的自動任務",
"labels": { "labels": {
@@ -495,18 +502,27 @@
"runNow": "立即執行", "runNow": "立即執行",
"pause": "暫停", "pause": "暫停",
"resume": "恢復", "resume": "恢復",
"edit": "編輯",
"delete": "刪除", "delete": "刪除",
"protected": "受保護", "protected": "受保護",
"editTitle": "編輯自動任務",
"editDescription": "更新提示詞和排程;關聯會話不會改變。",
"save": "儲存",
"deleteTitle": "刪除自動任務", "deleteTitle": "刪除自動任務",
"deleteDescription": "這會從 cron 儲存中刪除 {{name}},歷史聊天訊息會保留在會話中。", "deleteDescription": "這會從 cron 儲存中刪除 {{name}},歷史聊天訊息會保留在會話中。",
"cancel": "取消", "cancel": "取消",
"status": { "status": {
"system": "系統", "system": "系統",
"pending": "等待中", "pending": "等待中",
"running": "正在執行",
"needsSetup": "需重新設定",
"paused": "已暫停", "paused": "已暫停",
"failed": "失敗", "failed": "失敗",
"completed": "已完成",
"noSchedule": "無排程",
"active": "執行中" "active": "執行中"
}, },
"legacyWarning": "這個舊版自動任務缺少目標會話。請從它應該執行的聊天或外部 channel 中重新建立。",
"origin": { "origin": {
"system": "系統", "system": "系統",
"unknown": "未關聯會話", "unknown": "未關聯會話",
@@ -534,11 +550,16 @@
"every": "每 {{duration}}", "every": "每 {{duration}}",
"cron": "Cron {{expr}}", "cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}", "cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "每天 {{time}}",
"weekdaysAt": "工作日 {{time}}",
"hourlyAt": "每小時第 {{minute}} 分鐘",
"hourlyWindow": "{{start}}-{{end}} 點每小時第 {{minute}} 分鐘",
"custom": "自訂排程" "custom": "自訂排程"
}, },
"next": { "next": {
"paused": "已暫停", "paused": "已暫停",
"pending": "即將執行", "pending": "正在執行",
"none": "沒有下次執行" "none": "沒有下次執行"
}, },
"last": { "last": {
@@ -549,7 +570,41 @@
"ok": "完成", "ok": "完成",
"error": "錯誤", "error": "錯誤",
"skipped": "已略過", "skipped": "已略過",
"recent": "最近執行" "recent": "最近執行",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
"created": "建立於 {{time}}",
"updated": "更新於 {{time}}"
},
"fields": {
"name": "名稱",
"message": "訊息",
"scheduleType": "排程類型",
"every": "每隔",
"unit": "單位",
"cronExpression": "Cron 表達式",
"timezone": "時區",
"runAt": "執行時間"
},
"scheduleTypes": {
"every": "間隔",
"cron": "Cron",
"at": "一次性"
},
"everyUnits": {
"second": "秒",
"minute": "分鐘",
"hour": "小時",
"day": "天"
},
"validation": {
"nameRequired": "名稱不能為空。",
"messageRequired": "訊息不能為空。",
"intervalRequired": "間隔必須是正整數。",
"cronRequired": "Cron 表達式不能為空。",
"timeRequired": "執行時間不能為空。",
"futureRequired": "執行時間必須晚於目前時間。"
}, },
"duration": { "duration": {
"lessThanSecond": "不到 1 秒" "lessThanSecond": "不到 1 秒"
+21
View File
@@ -1,5 +1,6 @@
import type { import type {
AutomationsPayload, AutomationsPayload,
AutomationUpdatePayload,
ChatSummary, ChatSummary,
CliAppsPayload, CliAppsPayload,
FilePreviewPayload, FilePreviewPayload,
@@ -213,6 +214,26 @@ export async function runAutomationAction(
); );
} }
export async function updateAutomation(
token: string,
id: string,
values: AutomationUpdatePayload,
base: string = "",
): Promise<AutomationsPayload> {
const query = new URLSearchParams();
query.set("id", id);
return request<AutomationsPayload>(
`${base}/api/webui/automations/update?${query}`,
token,
{
headers: {
"X-Nanobot-Automation-Values": JSON.stringify(values),
},
},
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkills( export async function fetchSkills(
token: string, token: string,
base: string = "", base: string = "",
+11
View File
@@ -142,6 +142,17 @@ export interface SessionAutomationJob {
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; } export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface AutomationsPayload { jobs: SessionAutomationJob[]; } export interface AutomationsPayload { jobs: SessionAutomationJob[]; }
export interface AutomationUpdatePayload {
name?: string;
message?: string;
schedule?: {
kind: "at" | "every" | "cron";
at_ms?: number;
every_ms?: number;
expr?: string;
tz?: string;
};
}
export interface SessionDeleteResult { export interface SessionDeleteResult {
deleted: boolean; deleted: boolean;
+23
View File
@@ -25,6 +25,7 @@ import {
runCliAppAction, runCliAppAction,
runMcpPresetAction, runMcpPresetAction,
saveCustomMcpServer, saveCustomMcpServer,
updateAutomation,
updateSidebarState, updateSidebarState,
updateImageGenerationSettings, updateImageGenerationSettings,
updateModelConfiguration, updateModelConfiguration,
@@ -123,6 +124,28 @@ describe("webui API helpers", () => {
); );
}); });
it("serializes workspace automation updates", async () => {
await updateAutomation("tok", "job 1/2", {
name: "Daily quiz",
message: "Ask the quiz",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
});
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=job+1%2F2",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Automation-Values": JSON.stringify({
name: "Daily quiz",
message: "Ask the quiz",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
}),
},
}),
);
});
it("fetches the WebUI skill summary", async () => { it("fetches the WebUI skill summary", async () => {
await fetchSkills("tok"); await fetchSkills("tok");