Merge PR #4330: feat(webui): add automation management view

feat(webui): add automation management view
This commit is contained in:
Xubin Ren
2026-06-17 01:55:39 +08:00
committed by GitHub
40 changed files with 4548 additions and 168 deletions
+11 -60
View File
@@ -1,6 +1,10 @@
# nanobot WebUI
# nanobot WebUI Source
The WebUI is the browser workbench served by `nanobot gateway`. If you installed `nanobot-ai` from PyPI, the WebUI bundle is already included; this `webui/` source tree is only needed when you are changing the frontend.
This directory contains the React/TypeScript source for the nanobot WebUI. If
you installed `nanobot-ai` from PyPI and only want to use the bundled browser UI,
read the user guide in [`docs/webui.md`](../docs/webui.md). You do not need
Node.js, Bun, Vite, or anything in this directory unless you are changing the
frontend.
For the project overview, install guide, and general docs map, see the root [`README.md`](../README.md) and [`docs/README.md`](../docs/README.md).
@@ -8,46 +12,14 @@ For the project overview, install guide, and general docs map, see the root [`RE
| Goal | Start with | Opens at |
|---|---|---|
| Use the bundled browser UI | [Just want to use the WebUI?](#just-want-to-use-the-webui) | `http://127.0.0.1:8765` |
| Use the WebUI from another device | [Access from another device (LAN)](#access-from-another-device-lan) | `http://<your-ip>:8765` |
| Use the bundled browser UI | [`docs/webui.md`](../docs/webui.md) | `http://127.0.0.1:8765` |
| Use the WebUI from another device | [`docs/webui.md#lan-access`](../docs/webui.md#lan-access) | `http://<your-ip>:8765` |
| Change WebUI source code | [Develop the WebUI (Vite HMR)](#develop-the-webui-vite-hmr) | `http://127.0.0.1:5173` |
| Debug setup failures | [`docs/troubleshooting.md#webui-problems`](../docs/troubleshooting.md#webui-problems) | Diagnosis order and common fixes |
## Just want to use the WebUI?
If you installed nanobot via `python -m pip install nanobot-ai`, the WebUI is **already bundled** in the wheel. You do **not** need Node.js, Bun, Vite, or anything in this directory unless you are changing the WebUI source code.
First prove the provider path:
```bash
nanobot agent -m "Hello!"
```
If the shell cannot find `nanobot`, use the module form from the same Python environment:
```bash
python -m nanobot agent -m "Hello!"
```
Then merge this WebSocket snippet into your existing `~/.nanobot/config.json` instead of replacing the whole file:
```json
{ "channels": { "websocket": { "enabled": true } } }
```
If you are new to JSON snippets, see [`docs/start-without-technical-background.md#how-to-merge-json-snippets`](../docs/start-without-technical-background.md#how-to-merge-json-snippets).
Start the gateway:
```bash
nanobot gateway
```
Leave this terminal running while you use the WebUI. Closing it stops the browser UI and WebSocket connection.
Open [`http://127.0.0.1:8765`](http://127.0.0.1:8765). The gateway's `18790` port is only the health endpoint, not the browser UI. For setup failures, use [`docs/troubleshooting.md`](../docs/troubleshooting.md#webui-problems).
This `webui/` tree is for people **changing the WebUI source code**. It is built with Vite + React 18 + TypeScript + Tailwind 3 + shadcn/ui, talks to the gateway over the WebSocket multiplex protocol, and reads session metadata from the embedded REST surface on the same port.
The source app is built with Vite + React 18 + TypeScript + Tailwind 3 +
shadcn/ui. It talks to the gateway over the WebSocket multiplex protocol and
reads session metadata from the embedded REST surface on the same port.
## Layout
@@ -104,27 +76,6 @@ If your gateway listens on a non-default port, point the dev server at it:
NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
```
### Access from another device (LAN)
To use the WebUI from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
```json
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 8765,
"tokenIssueSecret": "your-secret-here"
}
}
}
```
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
Then open `http://<your-ip>:8765` on the other device. The WebUI will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
## Build for packaged runtime
You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel.
+54 -21
View File
@@ -65,14 +65,15 @@ type BootState =
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1";
const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings" | "apps" | "skills";
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
type ShellRoute = {
view: ShellView;
activeKey: string | null;
@@ -87,6 +88,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"voice",
"browser",
"apps",
"automations",
"skills",
"runtime",
"advanced",
@@ -101,7 +103,7 @@ function defaultShellRoute(): ShellRoute {
}
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
if (section === "apps" || section === "skills") return section;
if (section === "apps" || section === "automations" || section === "skills") return section;
return "settings";
}
@@ -130,6 +132,9 @@ function readShellRoute(): ShellRoute {
if (path === "/apps") {
return { view: "apps", activeKey, settingsSection: "apps" };
}
if (path === "/automations") {
return { view: "automations", activeKey, settingsSection: "automations" };
}
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
@@ -255,10 +260,12 @@ function readSidebarOpen(): boolean {
}
}
function readCompletedRunChatIds(): Set<string> {
function readSessionUpdateChatIds(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY);
const raw =
window.localStorage.getItem(SESSION_UPDATES_STORAGE_KEY)
?? window.localStorage.getItem(LEGACY_COMPLETED_RUNS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((item): item is string => typeof item === "string"));
@@ -267,10 +274,10 @@ function readCompletedRunChatIds(): Set<string> {
}
}
function writeCompletedRunChatIds(chatIds: Set<string>): void {
function writeSessionUpdateChatIds(chatIds: Set<string>): void {
try {
window.localStorage.setItem(
COMPLETED_RUNS_STORAGE_KEY,
SESSION_UPDATES_STORAGE_KEY,
JSON.stringify(Array.from(chatIds)),
);
} catch {
@@ -570,7 +577,7 @@ function Shell({
const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const skills = useSkills(token);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
@@ -638,20 +645,20 @@ function Shell({
}, [hostSidebarOpen]);
useEffect(() => {
writeCompletedRunChatIds(completedChatIds);
}, [completedChatIds]);
writeSessionUpdateChatIds(updatedChatIds);
}, [updatedChatIds]);
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
useEffect(() => {
activeChatIdRef.current = activeChatId;
if (!activeChatId) return;
setCompletedChatIds((current) => {
setUpdatedChatIds((current) => {
if (!current.has(activeChatId)) return current;
const next = new Set(current);
next.delete(activeChatId);
@@ -691,7 +698,7 @@ function Shell({
useEffect(() => {
if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId));
setCompletedChatIds((current) => {
setUpdatedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
);
@@ -719,12 +726,25 @@ function Shell({
}, [activeKey, loading, navigate, sessions]);
useEffect(() => {
return client.onSessionUpdate((_chatId, _scope, workspaceScope) => {
return client.onSessionUpdate((chatId, scope, workspaceScope) => {
if (scope === "thread") {
setUpdatedChatIds((current) => {
const next = new Set(current);
if (activeChatIdRef.current === chatId) {
next.delete(chatId);
} else {
next.add(chatId);
}
return next.size === current.size && next.has(chatId) === current.has(chatId)
? current
: next;
});
}
if (!workspaceScope) return;
const next = normalizeWorkspaceScope(workspaceScope);
setWorkspaceOverrides((current) => ({
...current,
[_chatId]: next,
[chatId]: next,
}));
setDraftWorkspaceScope(next);
setWorkspaceError(null);
@@ -761,7 +781,7 @@ function Shell({
runningChatIdsRef.current = next;
return next;
});
setCompletedChatIds((current) => {
setUpdatedChatIds((current) => {
let changed = false;
const next = new Set(current);
for (const chatId of activeRunIds) {
@@ -958,7 +978,7 @@ function Shell({
const selected = sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId;
if (selectedChatId) {
setCompletedChatIds((current) => {
setUpdatedChatIds((current) => {
if (!current.has(selectedChatId)) return current;
const next = new Set(current);
next.delete(selectedChatId);
@@ -1166,6 +1186,12 @@ function Shell({
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onOpenAutomations = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "automations", activeKey, settingsSection: "automations" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onOpenSkills = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "skills", activeKey, settingsSection: "skills" });
@@ -1223,7 +1249,7 @@ function Shell({
nextRunning.add(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
setCompletedChatIds((current) => {
setUpdatedChatIds((current) => {
if (!current.has(chatId)) return current;
const next = new Set(current);
next.delete(chatId);
@@ -1237,7 +1263,7 @@ function Shell({
nextRunning.delete(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
setCompletedChatIds((current) => {
setUpdatedChatIds((current) => {
const next = new Set(current);
if (activeChatIdRef.current === chatId) {
next.delete(chatId);
@@ -1341,6 +1367,12 @@ function Shell({
});
return;
}
if (view === "automations") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.automations", { defaultValue: "Automations" }),
});
return;
}
if (view === "skills") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.skills", { defaultValue: "Skills" }),
@@ -1367,9 +1399,10 @@ function Shell({
onNewChatInProject,
onOpenSettings,
onOpenApps,
onOpenAutomations,
onOpenSkills,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" || view === "skills" ? view : null,
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
onToggleArchived,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
@@ -1377,7 +1410,7 @@ function Shell({
projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList,
completedChatIds: completedChatIdList,
updatedChatIds: updatedChatIdList,
viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length,
+9 -9
View File
@@ -60,7 +60,7 @@ interface ChatListProps {
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
updatedChatIds?: string[];
density?: SidebarDensity;
showPreviews?: boolean;
showTimestamps?: boolean;
@@ -89,7 +89,7 @@ export const ChatList = memo(function ChatList({
projectNameOverrides = {},
collapsedGroups = {},
runningChatIds = [],
completedChatIds = [],
updatedChatIds = [],
density = "comfortable",
showPreviews = false,
showTimestamps = false,
@@ -175,7 +175,7 @@ export const ChatList = memo(function ChatList({
const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
const completed = new Set(completedChatIds);
const updated = new Set(updatedChatIds);
const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
@@ -245,8 +245,8 @@ export const ChatList = memo(function ChatList({
const projectMode = group.kind === "project";
const activityState = running.has(s.chatId)
? "running"
: completed.has(s.chatId) && !active
? "complete"
: updated.has(s.chatId) && !active
? "updated"
: null;
return (
<li key={s.key} className="min-w-0">
@@ -525,7 +525,7 @@ function ChatsFoldFooter({
function SessionActivityIndicator({
state,
}: {
state: "running" | "complete" | null;
state: "running" | "updated" | null;
}) {
const { t } = useTranslation();
@@ -542,15 +542,15 @@ function SessionActivityIndicator({
);
}
if (state === "complete") {
const label = t("chat.activity.complete");
if (state === "updated") {
const label = t("chat.activity.updated");
return (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" />
<span className="h-2 w-2 rounded-full bg-[#ff8a3d] shadow-[0_0_0_2px_rgba(255,138,61,0.16)]" />
</span>
);
}
+3 -3
View File
@@ -80,16 +80,16 @@ export function DeleteConfirm({
</div>
) : null}
</AlertDialogHeader>
<AlertDialogFooter className="mt-7 grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2">
<AlertDialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<AlertDialogCancel
onClick={onCancel}
className="mt-0 h-11 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
className="mt-0 h-11 w-full min-w-0 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{t("deleteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="h-11 rounded-full bg-destructive px-5 text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
className="h-11 w-full min-w-0 !whitespace-normal rounded-full bg-destructive px-5 text-center text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
>
{hasAutomations
? t("deleteConfirm.confirmWithAutomations")
+12 -3
View File
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
import {
Archive,
Brain,
CalendarClock,
Menu,
Search,
Settings,
@@ -36,8 +37,9 @@ interface SidebarProps {
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSkills: () => void;
onOpenAutomations: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | "skills" | null;
activeUtility?: "apps" | "skills" | "automations" | null;
onToggleArchived: () => void;
onCollapse: () => void;
onExpand?: () => void;
@@ -49,7 +51,7 @@ interface SidebarProps {
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
updatedChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
@@ -166,6 +168,13 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "skills"}
icon={<Brain className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.automations", { defaultValue: "Automations" })}
onClick={props.onOpenAutomations}
active={props.activeUtility === "automations"}
icon={<CalendarClock className="h-4 w-4" />}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}
@@ -201,7 +210,7 @@ export function Sidebar(props: SidebarProps) {
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
updatedChatIds={props.updatedChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
File diff suppressed because it is too large Load Diff
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Change language"
},
"apps": "Apps",
"automations": "Automations",
"skills": {
"title": "Skills"
}
@@ -80,6 +81,7 @@
"runtime": "System",
"advanced": "Security",
"apps": "Apps",
"automations": "Automations",
"skills": "Skills"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Loading Apps...",
"empty": "No apps match this filter."
},
"automations": {
"filters": {
"all": "All",
"active": "Active",
"paused": "Paused",
"failed": "Needs attention",
"system": "System"
},
"sort": {
"next": "Next run",
"last": "Last run",
"updated": "Updated",
"name": "Name"
},
"search": "Search task, message, linked chat, or schedule",
"queue": "Queue",
"loading": "Loading automations...",
"noMatches": "No automations match this view.",
"empty": "No automations yet.",
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
"oneShot": "One-time",
"systemTask": "System-managed automation",
"labels": {
"schedule": "Schedule",
"next": "Next",
"origin": "Linked chat",
"created": "Created",
"updated": "Updated"
},
"runNow": "Run now",
"pause": "Pause",
"resume": "Resume",
"edit": "Edit",
"delete": "Delete",
"protected": "Protected",
"editTitle": "Edit automation",
"save": "Save",
"deleteTitle": "Delete automation",
"deleteDescription": "This removes {{name}} from the cron store. Past chat messages stay in the session.",
"cancel": "Cancel",
"status": {
"system": "System",
"running": "Running now",
"paused": "Paused",
"failed": "Failed",
"completed": "Completed",
"noSchedule": "No schedule",
"active": "Active"
},
"origin": {
"system": "System",
"unknown": "No linked chat"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "At {{time}}",
"every": "Every {{duration}}",
"cron": "Cron {{expr}}",
"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"
},
"next": {
"paused": "Paused",
"pending": "Running now",
"none": "No next run"
},
"message": {
"showMore": "Show full message",
"showLess": "Show less"
},
"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."
}
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
@@ -527,7 +650,8 @@
"newInProject": "Start a new chat in {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Delete",
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
"moreAutomations": "+ {{count}} more",
"confirmWithAutomations": "Delete chat and automations",
"confirmWithAutomations": "Delete",
"schedule": {
"at": "{{time}}",
"every": "Every {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Cambiar idioma"
},
"apps": "Apps",
"automations": "Automatizaciones",
"skills": {
"title": "Habilidades"
}
@@ -80,6 +81,7 @@
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Aplicaciones",
"automations": "Automatizaciones",
"skills": "Habilidades"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro."
},
"automations": {
"filters": {
"all": "Todas",
"active": "Activas",
"paused": "Pausadas",
"failed": "Requieren atención",
"system": "Sistema"
},
"sort": {
"next": "Próxima ejecución",
"last": "Última ejecución",
"updated": "Actualizada",
"name": "Nombre"
},
"search": "Buscar tarea, mensaje, chat vinculado u horario",
"queue": "Cola",
"loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
"empty": "Aún no hay automatizaciones.",
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
"oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema",
"labels": {
"schedule": "Programación",
"next": "Siguiente",
"origin": "Chat vinculado",
"created": "Creada",
"updated": "Actualizada"
},
"runNow": "Ejecutar ahora",
"pause": "Pausar",
"resume": "Reanudar",
"edit": "Editar",
"delete": "Eliminar",
"protected": "Protegida",
"editTitle": "Editar automatización",
"save": "Guardar",
"deleteTitle": "Eliminar automatización",
"deleteDescription": "Esto elimina {{name}} del almacén cron. Los mensajes de chat anteriores permanecen en la sesión.",
"cancel": "Cancelar",
"status": {
"system": "Sistema",
"running": "Ejecutándose ahora",
"paused": "Pausada",
"failed": "Fallida",
"completed": "Completada",
"noSchedule": "Sin programación",
"active": "Activa"
},
"origin": {
"system": "Sistema",
"unknown": "Sin chat vinculado"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "A las {{time}}",
"every": "Cada {{duration}}",
"cron": "Cron {{expr}}",
"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"
},
"next": {
"paused": "Pausada",
"pending": "Ejecutándose ahora",
"none": "Sin próxima ejecución"
},
"message": {
"showMore": "Mostrar mensaje completo",
"showLess": "Mostrar menos"
},
"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."
}
},
"oauth": {
"authentication": "Autenticación OAuth",
"signIn": "Iniciar sesión",
@@ -527,7 +650,8 @@
"newInProject": "Iniciar un chat nuevo en {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Eliminar",
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
"moreAutomations": "+ {{count}} más",
"confirmWithAutomations": "Eliminar chat y automatizaciones",
"confirmWithAutomations": "Eliminar",
"schedule": {
"at": "{{time}}",
"every": "Cada {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Changer de langue"
},
"apps": "Apps",
"automations": "Automatisations",
"skills": {
"title": "Compétences"
}
@@ -80,6 +81,7 @@
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Applications",
"automations": "Automatisations",
"skills": "Compétences"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond."
},
"automations": {
"filters": {
"all": "Toutes",
"active": "Actives",
"paused": "En pause",
"failed": "À traiter",
"system": "Système"
},
"sort": {
"next": "Prochaine exécution",
"last": "Dernière exécution",
"updated": "Mise à jour",
"name": "Nom"
},
"search": "Rechercher tâche, message, discussion liée ou planning",
"queue": "File",
"loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.",
"empty": "Aucune automatisation pour le moment.",
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
"oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système",
"labels": {
"schedule": "Planning",
"next": "Prochaine",
"origin": "Discussion liée",
"created": "Créée",
"updated": "Modifiée"
},
"runNow": "Exécuter maintenant",
"pause": "Mettre en pause",
"resume": "Reprendre",
"edit": "Modifier",
"delete": "Supprimer",
"protected": "Protégée",
"editTitle": "Modifier lautomatisation",
"save": "Enregistrer",
"deleteTitle": "Supprimer lautomatisation",
"deleteDescription": "Cela supprime {{name}} du stockage cron. Les anciens messages de chat restent dans la session.",
"cancel": "Annuler",
"status": {
"system": "Système",
"running": "En cours dexécution",
"paused": "En pause",
"failed": "Échouée",
"completed": "Terminée",
"noSchedule": "Aucun planning",
"active": "En cours"
},
"origin": {
"system": "Système",
"unknown": "Aucune discussion liée"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "À {{time}}",
"every": "Toutes les {{duration}}",
"cron": "Cron {{expr}}",
"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é"
},
"next": {
"paused": "En pause",
"pending": "En cours dexécution",
"none": "Aucune prochaine exécution"
},
"message": {
"showMore": "Afficher le message complet",
"showLess": "Afficher moins"
},
"fields": {
"name": "Nom",
"message": "Message",
"scheduleType": "Type de planning",
"every": "Toutes les",
"unit": "Unité",
"cronExpression": "Expression cron",
"timezone": "Fuseau horaire",
"runAt": "Exécuter à"
},
"scheduleTypes": {
"every": "Intervalle",
"cron": "Cron",
"at": "Une fois"
},
"everyUnits": {
"second": "Secondes",
"minute": "Minutes",
"hour": "Heures",
"day": "Jours"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"messageRequired": "Le message est obligatoire.",
"intervalRequired": "Lintervalle doit être un nombre positif.",
"cronRequired": "Lexpression cron est obligatoire.",
"timeRequired": "Lheure dexécution est obligatoire.",
"futureRequired": "Lheure dexécution doit être dans le futur."
}
},
"oauth": {
"authentication": "Authentification OAuth",
"signIn": "Se connecter",
@@ -527,7 +650,8 @@
"newInProject": "Démarrer une nouvelle discussion dans {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Supprimer",
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
"moreAutomations": "+ {{count}} autres",
"confirmWithAutomations": "Supprimer la discussion et les automatisations",
"confirmWithAutomations": "Supprimer",
"schedule": {
"at": "{{time}}",
"every": "Tous les {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Ganti bahasa"
},
"apps": "Aplikasi",
"automations": "Otomasi",
"skills": {
"title": "Skill"
}
@@ -80,6 +81,7 @@
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi",
"automations": "Otomasi",
"skills": "Skill"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok."
},
"automations": {
"filters": {
"all": "Semua",
"active": "Aktif",
"paused": "Dijeda",
"failed": "Perlu ditangani",
"system": "Sistem"
},
"sort": {
"next": "Jalankan berikutnya",
"last": "Jalankan terakhir",
"updated": "Diperbarui",
"name": "Nama"
},
"search": "Cari tugas, pesan, chat terkait, atau jadwal",
"queue": "Antrean",
"loading": "Memuat otomasi...",
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
"empty": "Belum ada otomasi.",
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
"oneShot": "Satu kali",
"systemTask": "Automasi yang dikelola sistem",
"labels": {
"schedule": "Jadwal",
"next": "Berikutnya",
"origin": "Chat tertaut",
"created": "Dibuat",
"updated": "Diperbarui"
},
"runNow": "Jalankan sekarang",
"pause": "Jeda",
"resume": "Lanjutkan",
"edit": "Edit",
"delete": "Hapus",
"protected": "Terlindungi",
"editTitle": "Edit otomasi",
"save": "Simpan",
"deleteTitle": "Hapus otomasi",
"deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.",
"cancel": "Batal",
"status": {
"system": "Sistem",
"running": "Sedang berjalan",
"paused": "Dijeda",
"failed": "Gagal",
"completed": "Selesai",
"noSchedule": "Tanpa jadwal",
"active": "Aktif"
},
"origin": {
"system": "Sistem",
"unknown": "Tidak ada chat tertaut"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "Pada {{time}}",
"every": "Setiap {{duration}}",
"cron": "Cron {{expr}}",
"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"
},
"next": {
"paused": "Dijeda",
"pending": "Sedang berjalan",
"none": "Tidak ada jadwal berikutnya"
},
"message": {
"showMore": "Tampilkan pesan lengkap",
"showLess": "Tampilkan lebih sedikit"
},
"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."
}
},
"oauth": {
"authentication": "Autentikasi OAuth",
"signIn": "Masuk",
@@ -527,7 +650,8 @@
"newInProject": "Mulai obrolan baru di {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Hapus",
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
"moreAutomations": "+ {{count}} lagi",
"confirmWithAutomations": "Hapus obrolan dan automasi",
"confirmWithAutomations": "Hapus",
"schedule": {
"at": "{{time}}",
"every": "Setiap {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "言語を変更"
},
"apps": "アプリ",
"automations": "自動タスク",
"skills": {
"title": "スキル"
}
@@ -80,6 +81,7 @@
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ",
"automations": "自動タスク",
"skills": "スキル"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "アプリを読み込み中...",
"empty": "一致するアプリはありません。"
},
"automations": {
"filters": {
"all": "すべて",
"active": "実行中",
"paused": "一時停止",
"failed": "要対応",
"system": "システム"
},
"sort": {
"next": "次回実行",
"last": "前回実行",
"updated": "更新日時",
"name": "名前"
},
"search": "タスク、メッセージ、関連チャット、予定を検索",
"queue": "キュー",
"loading": "自動タスクを読み込み中...",
"noMatches": "この表示に一致する自動タスクはありません。",
"empty": "自動タスクはまだありません。",
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
"oneShot": "一回限り",
"systemTask": "システム管理の自動タスク",
"labels": {
"schedule": "スケジュール",
"next": "次回",
"origin": "関連チャット",
"created": "作成",
"updated": "更新"
},
"runNow": "今すぐ実行",
"pause": "一時停止",
"resume": "再開",
"edit": "編集",
"delete": "削除",
"protected": "保護済み",
"editTitle": "自動タスクを編集",
"save": "保存",
"deleteTitle": "自動タスクを削除",
"deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。",
"cancel": "キャンセル",
"status": {
"system": "システム",
"running": "実行中",
"paused": "一時停止",
"failed": "失敗",
"completed": "完了",
"noSchedule": "スケジュールなし",
"active": "実行中"
},
"origin": {
"system": "システム",
"unknown": "関連チャットなし"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "{{time}}",
"every": "{{duration}} ごと",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "毎日 {{time}}",
"weekdaysAt": "平日 {{time}}",
"hourlyAt": "毎時 :{{minute}}",
"hourlyWindow": "{{start}}-{{end}} の毎時 :{{minute}}",
"custom": "カスタムスケジュール"
},
"next": {
"paused": "一時停止",
"pending": "実行中",
"none": "次回実行なし"
},
"message": {
"showMore": "メッセージ全文を表示",
"showLess": "折りたたむ"
},
"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": "実行日時は現在より後にしてください。"
}
},
"oauth": {
"authentication": "OAuth 認証",
"signIn": "サインイン",
@@ -527,7 +650,8 @@
"newInProject": "「{{project}}」で新しいチャットを開始",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "削除",
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
"moreAutomations": "他 {{count}} 件",
"confirmWithAutomations": "チャットと自動タスクを削除",
"confirmWithAutomations": "削除",
"schedule": {
"at": "{{time}}",
"every": "{{duration}} ごと",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "언어 변경"
},
"apps": "앱",
"automations": "자동화",
"skills": {
"title": "스킬"
}
@@ -80,6 +81,7 @@
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱",
"automations": "자동화",
"skills": "스킬"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "앱을 불러오는 중...",
"empty": "일치하는 앱이 없습니다."
},
"automations": {
"filters": {
"all": "전체",
"active": "활성",
"paused": "일시 중지",
"failed": "확인 필요",
"system": "시스템"
},
"sort": {
"next": "다음 실행",
"last": "마지막 실행",
"updated": "업데이트",
"name": "이름"
},
"search": "작업, 메시지, 연결된 채팅 또는 일정 검색",
"queue": "대기열",
"loading": "자동화를 불러오는 중...",
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
"empty": "아직 자동화가 없습니다.",
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
"oneShot": "일회성",
"systemTask": "시스템 관리 자동화",
"labels": {
"schedule": "일정",
"next": "다음",
"origin": "연결된 채팅",
"created": "생성",
"updated": "업데이트"
},
"runNow": "지금 실행",
"pause": "일시 중지",
"resume": "재개",
"edit": "편집",
"delete": "삭제",
"protected": "보호됨",
"editTitle": "자동화 편집",
"save": "저장",
"deleteTitle": "자동화 삭제",
"deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.",
"cancel": "취소",
"status": {
"system": "시스템",
"running": "실행 중",
"paused": "일시 중지",
"failed": "실패",
"completed": "완료",
"noSchedule": "일정 없음",
"active": "활성"
},
"origin": {
"system": "시스템",
"unknown": "연결된 채팅 없음"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "{{time}}",
"every": "{{duration}}마다",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "매일 {{time}}",
"weekdaysAt": "평일 {{time}}",
"hourlyAt": "매시간 :{{minute}}",
"hourlyWindow": "{{start}}-{{end}} 사이 매시간 :{{minute}}",
"custom": "사용자 지정 일정"
},
"next": {
"paused": "일시 중지",
"pending": "실행 중",
"none": "다음 실행 없음"
},
"message": {
"showMore": "전체 메시지 보기",
"showLess": "접기"
},
"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": "실행 시간은 현재보다 이후여야 합니다."
}
},
"oauth": {
"authentication": "OAuth 인증",
"signIn": "로그인",
@@ -527,7 +650,8 @@
"newInProject": "{{project}}에서 새 채팅 시작",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "삭제",
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
"moreAutomations": "+ {{count}}개 더",
"confirmWithAutomations": "채팅과 자동화 삭제",
"confirmWithAutomations": "삭제",
"schedule": {
"at": "{{time}}",
"every": "{{duration}}마다",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "Đổi ngôn ngữ"
},
"apps": "Ứng dụng",
"automations": "Tự động hóa",
"skills": {
"title": "Kỹ năng"
}
@@ -80,6 +81,7 @@
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng",
"automations": "Tự động hóa",
"skills": "Kỹ năng"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng phù hợp."
},
"automations": {
"filters": {
"all": "Tất cả",
"active": "Đang chạy",
"paused": "Đã tạm dừng",
"failed": "Cần xử lý",
"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, cuộc trò chuyện liên kết hoặc lịch",
"queue": "Hàng đợi",
"loading": "Đang tải tự động hóa...",
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
"empty": "Chưa có tự động hóa.",
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
"oneShot": "Một lần",
"systemTask": "Tự động hóa do hệ thống quản lý",
"labels": {
"schedule": "Lịch",
"next": "Tiếp theo",
"origin": "Cuộc trò chuyện liên kết",
"created": "Đã tạo",
"updated": "Đã cập nhật"
},
"runNow": "Chạy ngay",
"pause": "Tạm dừng",
"resume": "Tiếp tục",
"edit": "Sửa",
"delete": "Xóa",
"protected": "Được bảo vệ",
"editTitle": "Sửa tự động hóa",
"save": "Lưu",
"deleteTitle": "Xóa tự động hóa",
"deleteDescription": "Thao tác này xóa {{name}} khỏi kho cron. Tin nhắn chat trước đó vẫn ở trong phiên.",
"cancel": "Hủy",
"status": {
"system": "Hệ thống",
"running": "Đang chạy",
"paused": "Đã tạm dừng",
"failed": "Thất bại",
"completed": "Hoàn tất",
"noSchedule": "Không có lịch",
"active": "Đang chạy"
},
"origin": {
"system": "Hệ thống",
"unknown": "Chưa liên kết cuộc trò chuyện"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "DingTalk",
"discord": "Discord",
"email": "Email",
"feishu": "Feishu",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "WeChat",
"wecom": "WeCom",
"weixin": "WeChat",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "Vào {{time}}",
"every": "Mỗi {{duration}}",
"cron": "Cron {{expr}}",
"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"
},
"next": {
"paused": "Đã tạm dừng",
"pending": "Đang chạy",
"none": "Không có lần chạy tiếp theo"
},
"message": {
"showMore": "Hiển thị toàn bộ tin nhắn",
"showLess": "Thu gọn"
},
"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."
}
},
"oauth": {
"authentication": "Xác thực OAuth",
"signIn": "Đăng nhập",
@@ -527,7 +650,8 @@
"newInProject": "Bắt đầu cuộc trò chuyện mới trong {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -562,7 +686,7 @@
"confirm": "Xóa",
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
"moreAutomations": "+ {{count}} mục nữa",
"confirmWithAutomations": "Xóa trò chuyện và tự động hóa",
"confirmWithAutomations": "Xóa",
"schedule": {
"at": "{{time}}",
"every": "Mỗi {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "切换语言"
},
"apps": "应用",
"automations": "自动任务",
"skills": {
"title": "技能"
}
@@ -80,6 +81,7 @@
"runtime": "系统",
"advanced": "安全",
"apps": "应用",
"automations": "自动任务",
"skills": "技能"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "正在加载应用...",
"empty": "没有匹配的应用。"
},
"automations": {
"filters": {
"all": "全部",
"active": "运行中",
"paused": "已暂停",
"failed": "异常",
"system": "系统"
},
"sort": {
"next": "下次运行",
"last": "上次运行",
"updated": "更新时间",
"name": "名称"
},
"search": "搜索任务、消息、关联会话或计划",
"queue": "任务队列",
"loading": "正在加载自动任务...",
"noMatches": "当前视图没有匹配的自动任务。",
"empty": "暂无自动任务。",
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
"oneShot": "一次性",
"systemTask": "系统管理的自动任务",
"labels": {
"schedule": "计划",
"next": "下次",
"origin": "关联会话",
"created": "创建于",
"updated": "更新于"
},
"runNow": "立即运行",
"pause": "暂停",
"resume": "恢复",
"edit": "编辑",
"delete": "删除",
"protected": "受保护",
"editTitle": "编辑自动任务",
"save": "保存",
"deleteTitle": "删除自动任务",
"deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。",
"cancel": "取消",
"status": {
"system": "系统",
"running": "正在运行",
"paused": "已暂停",
"failed": "失败",
"completed": "已完成",
"noSchedule": "无计划",
"active": "运行中"
},
"origin": {
"system": "系统",
"unknown": "未关联会话"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "钉钉",
"discord": "Discord",
"email": "邮件",
"feishu": "飞书",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "微信",
"wecom": "企业微信",
"weixin": "微信",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "在 {{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "每天 {{time}}",
"weekdaysAt": "工作日 {{time}}",
"hourlyAt": "每小时第 {{minute}} 分钟",
"hourlyWindow": "{{start}}-{{end}} 点每小时第 {{minute}} 分钟",
"custom": "自定义计划"
},
"next": {
"paused": "已暂停",
"pending": "正在运行",
"none": "没有下次运行"
},
"message": {
"showMore": "查看完整消息",
"showLess": "收起消息"
},
"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": "运行时间必须晚于当前时间。"
}
},
"oauth": {
"authentication": "OAuth 认证",
"signIn": "登录",
@@ -527,7 +650,8 @@
"newInProject": "在 {{project}} 中开始新对话",
"activity": {
"running": "Agent 正在运行",
"complete": "Agent 已完成"
"complete": "Agent 已完成",
"updated": "有新内容"
},
"pin": "置顶",
"unpin": "取消置顶",
@@ -562,7 +686,7 @@
"confirm": "删除",
"automationsDescription": "这个对话有关联的自动任务。删除对话也会删除这些自动任务。",
"moreAutomations": "另有 {{count}} 个",
"confirmWithAutomations": "删除对话和自动任务",
"confirmWithAutomations": "删除",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
+126 -2
View File
@@ -55,6 +55,7 @@
"ariaLabel": "切換語言"
},
"apps": "應用",
"automations": "自動任務",
"skills": {
"title": "技能"
}
@@ -80,6 +81,7 @@
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用",
"automations": "自動任務",
"skills": "技能"
},
"sections": {
@@ -469,6 +471,127 @@
"loading": "正在載入應用...",
"empty": "沒有符合的應用。"
},
"automations": {
"filters": {
"all": "全部",
"active": "執行中",
"paused": "已暫停",
"failed": "異常",
"system": "系統"
},
"sort": {
"next": "下次執行",
"last": "上次執行",
"updated": "更新時間",
"name": "名稱"
},
"search": "搜尋任務、訊息、關聯對話或排程",
"queue": "任務佇列",
"loading": "正在載入自動任務...",
"noMatches": "目前檢視沒有符合的自動任務。",
"empty": "尚無自動任務。",
"emptyHint": "請從它應該執行的來源處建立,這樣 nanobot 才能保留正確上下文。",
"oneShot": "一次性",
"systemTask": "系統管理的自動任務",
"labels": {
"schedule": "排程",
"next": "下次",
"origin": "關聯會話",
"created": "建立於",
"updated": "更新於"
},
"runNow": "立即執行",
"pause": "暫停",
"resume": "恢復",
"edit": "編輯",
"delete": "刪除",
"protected": "受保護",
"editTitle": "編輯自動任務",
"save": "儲存",
"deleteTitle": "刪除自動任務",
"deleteDescription": "這會從 cron 儲存中刪除 {{name}},歷史聊天訊息會保留在會話中。",
"cancel": "取消",
"status": {
"system": "系統",
"running": "正在執行",
"paused": "已暫停",
"failed": "失敗",
"completed": "已完成",
"noSchedule": "無排程",
"active": "執行中"
},
"origin": {
"system": "系統",
"unknown": "未關聯會話"
},
"channels": {
"api": "API",
"cli": "CLI",
"dingtalk": "釘釘",
"discord": "Discord",
"email": "電子郵件",
"feishu": "飛書",
"matrix": "Matrix",
"msteams": "Microsoft Teams",
"qq": "QQ",
"slack": "Slack",
"telegram": "Telegram",
"wechat": "微信",
"wecom": "企業微信",
"weixin": "微信",
"whatsapp": "WhatsApp"
},
"schedule": {
"at": "於 {{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"withTz": "{{summary}} · {{tz}}",
"dailyAt": "每天 {{time}}",
"weekdaysAt": "工作日 {{time}}",
"hourlyAt": "每小時第 {{minute}} 分鐘",
"hourlyWindow": "{{start}}-{{end}} 點每小時第 {{minute}} 分鐘",
"custom": "自訂排程"
},
"next": {
"paused": "已暫停",
"pending": "正在執行",
"none": "沒有下次執行"
},
"message": {
"showMore": "查看完整訊息",
"showLess": "收起訊息"
},
"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": "執行時間必須晚於目前時間。"
}
},
"oauth": {
"authentication": "OAuth 認證",
"signIn": "登入",
@@ -527,7 +650,8 @@
"newInProject": "在 {{project}} 中開始新對話",
"activity": {
"running": "Agent 正在執行",
"complete": "Agent 已完成"
"complete": "Agent 已完成",
"updated": "有新內容"
},
"pin": "置頂",
"unpin": "取消置頂",
@@ -562,7 +686,7 @@
"confirm": "刪除",
"automationsDescription": "這個對話有關聯的自動任務。刪除對話也會刪除這些自動任務。",
"moreAutomations": "另有 {{count}} 個",
"confirmWithAutomations": "刪除對話和自動任務",
"confirmWithAutomations": "刪除",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
+52
View File
@@ -1,4 +1,6 @@
import type {
AutomationsPayload,
AutomationUpdatePayload,
ChatSummary,
CliAppsPayload,
FilePreviewPayload,
@@ -87,6 +89,10 @@ function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefin
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
}
function automationValuesHeader(values: AutomationUpdatePayload): HeadersInit {
return { "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)) };
}
function splitKey(key: string): { channel: string; chatId: string } {
const idx = key.indexOf(":");
if (idx === -1) return { channel: "", chatId: key };
@@ -184,6 +190,52 @@ export async function fetchSessionAutomations(
);
}
export async function fetchAutomations(
token: string,
base: string = "",
): Promise<AutomationsPayload> {
return request<AutomationsPayload>(
`${base}/api/webui/automations`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function runAutomationAction(
token: string,
action: "enable" | "disable" | "delete" | "run",
id: string,
base: string = "",
): Promise<AutomationsPayload> {
const query = new URLSearchParams();
query.set("id", id);
return request<AutomationsPayload>(
`${base}/api/webui/automations/${action}?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function 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: automationValuesHeader(values),
},
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkills(
token: string,
base: string = "",
+32
View File
@@ -100,6 +100,10 @@ export interface SessionAutomationJob {
id: string;
name: string;
enabled: boolean;
protected?: boolean;
delete_after_run?: boolean;
created_at_ms?: number | null;
updated_at_ms?: number | null;
schedule: {
kind: "at" | "every" | "cron" | string;
at_ms?: number | null;
@@ -109,15 +113,43 @@ export interface SessionAutomationJob {
};
payload: {
message: string;
kind?: "agent_turn" | "system_event" | string;
};
state: {
next_run_at_ms?: number | null;
last_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null;
last_error?: string | null;
pending?: boolean;
run_history?: Array<{
run_at_ms: number;
status: "ok" | "error" | "skipped" | string;
duration_ms?: number;
error?: string | null;
}>;
};
origin?: {
session_key?: string;
channel: string;
chat_id?: string;
title?: string;
preview?: string;
} | null;
}
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface AutomationsPayload { jobs: SessionAutomationJob[]; }
export interface AutomationUpdatePayload {
name?: string;
message?: string;
schedule?: {
kind: "at" | "every" | "cron";
at_ms?: number;
every_ms?: number;
expr?: string;
tz?: string;
};
}
export interface SessionDeleteResult {
deleted: boolean;
+46
View File
@@ -4,6 +4,7 @@ import {
createModelConfiguration,
deleteSession,
fetchFilePreview,
fetchAutomations,
fetchCliApps,
fetchInstalledCliApps,
fetchMcpPresets,
@@ -20,9 +21,11 @@ import {
listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
runAutomationAction,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
updateAutomation,
updateSidebarState,
updateImageGenerationSettings,
updateModelConfiguration,
@@ -99,6 +102,49 @@ describe("webui API helpers", () => {
);
});
it("fetches workspace automations", async () => {
await fetchAutomations("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes workspace automation actions", async () => {
await runAutomationAction("tok", "disable", "job 1/2");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/disable?id=job+1%2F2",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes workspace automation updates", async () => {
const values = {
name: "每日测验",
message: "Ask 今日 quiz",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
} as const;
await updateAutomation("tok", "job 1/2", values);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=job+1%2F2",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
},
}),
);
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
});
it("fetches the WebUI skill summary", async () => {
await fetchSkills("tok");
+398 -9
View File
@@ -13,6 +13,7 @@ const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
@@ -194,7 +195,10 @@ vi.mock("@/lib/nanobot-client", () => {
onRuntimeModelUpdate = () => () => {};
onError = () => () => {};
onChat = () => () => {};
onSessionUpdate = () => () => {};
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(handler);
};
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
runStatusHandlers.add(handler);
return () => runStatusHandlers.delete(handler);
@@ -227,10 +231,12 @@ describe("App layout", () => {
toggleThemeSpy.mockReset();
attachSpy.mockReset();
runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
setNavigatorPlatform("Linux x86_64");
localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
ws_path: "/",
@@ -265,6 +271,23 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
});
it("places Automations after Skills in the main sidebar", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
const skillsButton = within(sidebar).getByRole("button", { name: "Skills" });
const automationsButton = within(sidebar).getByRole("button", { name: "Automations" });
expect(appsButton.compareDocumentPosition(skillsButton) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
expect(
skillsButton.compareDocumentPosition(automationsButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("opens Skills from the main sidebar", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
@@ -334,6 +357,331 @@ describe("App layout", () => {
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
});
it("opens Automations from the main sidebar", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": {
jobs: [
{
id: "job-1",
name: "Daily repo check",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: {
message: "Check the repo status",
kind: "agent_turn",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0),
last_status: "ok",
pending: false,
run_history: [],
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "Release prep",
preview: "Check release blockers",
},
},
{
id: "external-quiz",
name: "WeChat quiz",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "cron", expr: "30 9-23 * * *", tz: "Asia/Shanghai" },
payload: {
message: "Send a quiz",
kind: "agent_turn",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 17, 11, 30, 0),
last_status: "ok",
pending: false,
run_history: [],
},
origin: {
channel: "weixin",
title: "",
preview: "",
},
},
{
id: "heartbeat",
name: "heartbeat",
enabled: true,
protected: true,
schedule: { kind: "every", every_ms: 60_000 },
payload: { message: "", kind: "system_event" },
state: { next_run_at_ms: null, pending: false, run_history: [] },
origin: null,
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const automationsButton = within(sidebar).getByRole("button", {
name: "Automations",
});
fireEvent.click(automationsButton);
const heading = await screen.findByRole("heading", { name: "Automations" });
expect(heading).toBeInTheDocument();
const automationsMain = heading.closest("main");
expect(automationsMain).not.toBeNull();
expect(within(automationsMain as HTMLElement).queryByText("Settings")).not.toBeInTheDocument();
expect(screen.getAllByText("Daily repo check").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Check the repo status").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Release prep").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("WeChat quiz")).toBeInTheDocument();
expect(screen.getByText("WeChat")).toBeInTheDocument();
expect(screen.queryByText("weixin:wx-chat")).not.toBeInTheDocument();
expect(screen.queryByText("memory with dream state")).not.toBeInTheDocument();
expect(screen.getByText("heartbeat")).toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Automations" })).toHaveAttribute(
"aria-current",
"page",
);
expect(document.title).toBe("Automations · nanobot");
const searchInput = within(automationsMain as HTMLElement).getByPlaceholderText(
"Search task, message, linked chat, or schedule",
);
fireEvent.change(searchInput, { target: { value: "WeChat" } });
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
fireEvent.change(searchInput, { target: { value: "09-23" } });
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
});
it("edits a past one-time automation without resubmitting its old schedule", async () => {
const pastOneShot = {
id: "past-one-shot",
name: "Past one-shot",
enabled: true,
protected: false,
delete_after_run: true,
schedule: { kind: "at", at_ms: 1 },
payload: {
message: "Old one-shot message",
kind: "agent_turn",
},
state: {
next_run_at_ms: null,
last_status: "ok",
pending: false,
run_history: [],
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "Release prep",
preview: "Check release blockers",
},
};
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": { jobs: [pastOneShot] },
"/api/webui/automations/update?id=past-one-shot": {
jobs: [
{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" }));
expect((await screen.findAllByText("Past one-shot")).length).toBeGreaterThanOrEqual(1);
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
expect(screen.queryByText("Run time must be in the future.")).not.toBeInTheDocument();
expect(
screen.queryByText("Update the prompt and schedule. The linked chat stays unchanged."),
).not.toBeInTheDocument();
expect(screen.getByDisplayValue("Old one-shot message")).toHaveClass(
"min-h-[160px]",
"resize-none",
);
fireEvent.change(screen.getByDisplayValue("Old one-shot message"), {
target: { value: "Updated one-shot message" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=past-one-shot",
expect.any(Object),
);
});
const updateCall = vi.mocked(fetch).mock.calls.find(
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
);
expect(updateCall).toBeTruthy();
const headers = updateCall?.[1]?.headers as Record<string, string>;
expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({
name: "Past one-shot",
message: "Updated one-shot message",
});
});
it("keeps long automation details expandable without nested scrolling", async () => {
const longMessage = [
"Review the release plan and prepare a concise status update for the channel.",
"Include blockers, owners, follow-up dates, and any risky assumptions that changed since yesterday.",
"Keep the output actionable and avoid repeating context that the team already confirmed in the thread.",
"If a dependency looks stale, call it out explicitly and ask for a fresh owner update.",
"This message is intentionally long enough to require progressive disclosure in the automation details panel.",
"The full content should remain available without forcing the user into a small nested scroll area.",
].join("\n");
const history = [
{ run_at_ms: Date.UTC(2026, 3, 12, 10, 0, 0), status: "error", duration_ms: 900, error: "oldest failure" },
{ run_at_ms: Date.UTC(2026, 3, 13, 10, 0, 0), status: "error", duration_ms: 800, error: "second oldest failure" },
{ run_at_ms: Date.UTC(2026, 3, 14, 10, 0, 0), status: "ok", duration_ms: 700 },
{ run_at_ms: Date.UTC(2026, 3, 15, 10, 0, 0), status: "ok", duration_ms: 600 },
{ run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0), status: "ok", duration_ms: 500 },
{ run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0), status: "ok", duration_ms: 400 },
];
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": {
jobs: [
{
id: "long-details",
name: "Long detail automation",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: {
message: longMessage,
kind: "agent_turn",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 18, 10, 0, 0),
last_status: "ok",
pending: false,
run_history: history,
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "Release prep",
preview: "Check release blockers",
},
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" }));
const detailHeading = await screen.findByRole("heading", { name: "Long detail automation" });
const detailPanel = detailHeading.closest("article") as HTMLElement;
expect(detailPanel).not.toBeNull();
const message = Array.from(detailPanel.querySelectorAll("section div")).find(
(node) => node.textContent === longMessage,
) as HTMLElement | undefined;
expect(message).toBeTruthy();
expect(message!).toHaveClass("line-clamp-6");
fireEvent.click(within(detailPanel).getByRole("button", { name: "Show full message" }));
expect(within(detailPanel).getByRole("button", { name: "Show less" })).toBeInTheDocument();
expect(message!).not.toHaveClass("line-clamp-6");
expect(within(detailPanel).queryByText("Recent health")).not.toBeInTheDocument();
expect(within(detailPanel).queryByRole("button", { name: /Run history/ })).not.toBeInTheDocument();
expect(within(detailPanel).queryByText(/oldest failure/)).not.toBeInTheDocument();
expect(within(detailPanel).queryByText("No error recorded")).not.toBeInTheDocument();
});
it("localizes the Automations surface", async () => {
await i18n.changeLanguage("zh-CN");
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": {
jobs: [
{
id: "job-zh",
name: "每日检查",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: {
message: "检查仓库状态",
kind: "agent_turn",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0),
last_run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0),
last_status: "ok",
pending: false,
run_history: [
{
run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0),
status: "ok",
duration_ms: 500,
},
],
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "发布准备",
preview: "检查发布阻塞项",
},
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" });
fireEvent.click(within(sidebar).getByRole("button", { name: "自动任务" }));
const heading = await screen.findByRole("heading", { name: "自动任务" });
expect(heading).toBeInTheDocument();
const automationsMain = heading.closest("main");
expect(automationsMain).not.toBeNull();
expect(within(automationsMain as HTMLElement).queryByText("设置")).not.toBeInTheDocument();
expect(screen.getByText("任务队列")).toBeInTheDocument();
expect(screen.getAllByText("每日检查").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("检查仓库状态").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("每 1天")).toBeInTheDocument();
expect(screen.queryByText("最近健康状态")).not.toBeInTheDocument();
expect(screen.queryByText("近期无问题")).not.toBeInTheDocument();
expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument();
expect(document.title).toBe("自动任务 · nanobot");
});
it("fully collapses the native host sidebar and previews it on hover", async () => {
mockSessions = [
{
@@ -497,7 +845,7 @@ describe("App layout", () => {
screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" }));
fireEvent.click(screen.getByRole("button", { name: "删除" }));
await waitFor(() =>
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", {
@@ -754,15 +1102,15 @@ describe("App layout", () => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
});
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("does not show a completed dot later when the active session finishes", async () => {
it("does not show an updated dot later when the active session finishes", async () => {
mockSessions = [
{
key: "websocket:chat-a",
@@ -806,12 +1154,53 @@ describe("App layout", () => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
});
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("marks inactive sessions when a thread update arrives", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Open chat",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Scheduled update target",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Open chat$/ }));
});
act(() => {
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
});
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
});
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("restores sidebar run indicators after a page reload", async () => {
@@ -835,7 +1224,7 @@ describe("App layout", () => {
},
];
localStorage.setItem(
"nanobot-webui.sidebar.completed-runs.v1",
"nanobot-webui.sidebar.session-updates.v1",
JSON.stringify(["chat-b"]),
);
@@ -846,7 +1235,7 @@ describe("App layout", () => {
await waitFor(() =>
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
);
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
expect(attachSpy).toHaveBeenCalledWith("chat-a");
});
+43 -5
View File
@@ -18,6 +18,44 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
}
describe("ChatList", () => {
it("orders chats by latest session activity by default", () => {
const sessions = [
session({
chatId: "older",
title: "Older chat",
updatedAt: "2026-05-21T10:00:00Z",
}),
session({
chatId: "newest",
title: "Newest chat",
updatedAt: "2026-05-21T12:00:00Z",
}),
session({
chatId: "middle",
title: "Middle chat",
updatedAt: "2026-05-21T11:00:00Z",
}),
];
render(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const chatsSection = screen.getAllByRole("region")[0];
const text = chatsSection.textContent ?? "";
expect(text.indexOf("Newest chat")).toBeLessThan(text.indexOf("Middle chat"));
expect(text.indexOf("Middle chat")).toBeLessThan(text.indexOf("Older chat"));
});
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
const sessions = [
session({
@@ -179,7 +217,7 @@ describe("ChatList", () => {
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
});
it("hides the completed dot for the active chat", () => {
it("hides the updated dot for the active chat", () => {
const sessions = [
session({
chatId: "active",
@@ -200,13 +238,13 @@ describe("ChatList", () => {
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
completedChatIds={["active", "done"]}
updatedChatIds={["active", "done"]}
/>,
);
const finished = screen.getAllByLabelText("Agent finished");
expect(finished).toHaveLength(1);
expect(finished[0].firstElementChild).toHaveClass("h-2", "w-2");
const updated = screen.getAllByLabelText("New activity");
expect(updated).toHaveLength(1);
expect(updated[0].firstElementChild).toHaveClass("h-2", "w-2");
});
it("folds long default workspace chats and can show all", () => {
+10
View File
@@ -31,6 +31,7 @@ const SETTINGS_NAV_KEYS = [
"image",
"browser",
"apps",
"automations",
"runtime",
"advanced",
];
@@ -43,8 +44,17 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.nav.models",
"settings.nav.providers",
"settings.nav.apps",
"settings.nav.automations",
"settings.nav.runtime",
"settings.nav.advanced",
"sidebar.automations",
"settings.automations.filters.active",
"settings.automations.queue",
"settings.automations.empty",
"settings.automations.systemTask",
"settings.automations.labels.schedule",
"settings.automations.status.active",
"settings.automations.deleteTitle",
"settings.sections.interface",
"settings.sections.localPreferences",
"settings.sections.webSearch",
+22 -1
View File
@@ -159,8 +159,9 @@ const installedAnyGen = {
function renderSettingsView(
options: {
initialSection?: "overview" | "apps" | "advanced" | "models";
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models";
initialSettings?: SettingsPayload;
showSidebar?: boolean;
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
@@ -171,6 +172,7 @@ function renderSettingsView(
theme="light"
initialSection={options.initialSection ?? "apps"}
initialSettings={options.initialSettings}
showSidebar={options.showSidebar}
onToggleTheme={() => {}}
onBackToChat={() => {}}
onModelNameChange={() => {}}
@@ -187,6 +189,25 @@ describe("SettingsView Apps catalog", () => {
vi.unstubAllGlobals();
});
it("does not show the Settings kicker on the standalone Automations surface", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") return jsonResponse({ jobs: [] });
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
});
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);