fix(webui): improve automation management
This commit is contained in:
@@ -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:")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 s’exé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 l’automatisation",
|
||||||
|
"editDescription": "Modifiez le prompt et le planning. La discussion liée ne change pas.",
|
||||||
|
"save": "Enregistrer",
|
||||||
"deleteTitle": "Supprimer l’automatisation",
|
"deleteTitle": "Supprimer l’automatisation",
|
||||||
"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 d’exé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 n’a pas de discussion cible. Recréez-la depuis la discussion ou le canal où elle doit s’exé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 d’exé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": "L’intervalle doit être un nombre positif.",
|
||||||
|
"cronRequired": "L’expression cron est obligatoire.",
|
||||||
|
"timeRequired": "L’heure d’exécution est obligatoire.",
|
||||||
|
"futureRequired": "L’heure d’exécution doit être dans le futur."
|
||||||
},
|
},
|
||||||
"duration": {
|
"duration": {
|
||||||
"lessThanSecond": "moins de 1 seconde"
|
"lessThanSecond": "moins de 1 seconde"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 秒未満"
|
||||||
|
|||||||
@@ -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초 미만"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 秒"
|
||||||
|
|||||||
@@ -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 秒"
|
||||||
|
|||||||
@@ -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 = "",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user