mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-15 00:29:52 +03:00
1526 lines
58 KiB
TypeScript
1526 lines
58 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import type { FormEvent, ReactNode } from "react";
|
|
import {
|
|
ArrowUpDown,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CircleAlert,
|
|
Clipboard,
|
|
ExternalLink,
|
|
Loader2,
|
|
PauseCircle,
|
|
Pencil,
|
|
PlayCircle,
|
|
Search,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
|
import { SETTINGS_SEARCH_INPUT_CLASS } from "@/components/settings/shared/SettingsControls";
|
|
import { AppsActionButton } from "@/components/settings/system/AppsSettings";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { formControlFocusClassName } from "@/components/ui/form-control";
|
|
import { Input } from "@/components/ui/input";
|
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
|
import { fmtDateTime, relativeTime } from "@/lib/format";
|
|
import type { AutomationsPayload, AutomationUpdatePayload, SessionAutomationJob } from "@/lib/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
|
|
export type AutomationSort = "next" | "last" | "updated" | "name";
|
|
export type AutomationAction = "enable" | "disable" | "delete" | "run";
|
|
|
|
export function AutomationsSettings({
|
|
payload,
|
|
loading,
|
|
query,
|
|
filter,
|
|
sort,
|
|
actionKey,
|
|
error,
|
|
onQueryChange,
|
|
onFilterChange,
|
|
onSortChange,
|
|
onAction,
|
|
onRequestEdit,
|
|
onRequestDelete,
|
|
onBackToChat,
|
|
}: {
|
|
payload: AutomationsPayload | null;
|
|
loading: boolean;
|
|
query: string;
|
|
filter: AutomationFilter;
|
|
sort: AutomationSort;
|
|
actionKey: string | null;
|
|
error: string | null;
|
|
onQueryChange: (value: string) => void;
|
|
onFilterChange: (value: AutomationFilter) => void;
|
|
onSortChange: (value: AutomationSort) => void;
|
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
|
onBackToChat: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
const jobs = payload?.jobs ?? [];
|
|
const locale = i18n.resolvedLanguage || i18n.language;
|
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
|
const filtered = useMemo(() => {
|
|
const searchTokens = parseAutomationSearchQuery(query);
|
|
return sortAutomationJobs(jobs, sort)
|
|
.filter((job) => automationMatchesFilter(job, filter))
|
|
.filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens));
|
|
}, [filter, jobs, query, sort]);
|
|
const activeCount = jobs.filter((job) => {
|
|
const key = automationStatusKey(job);
|
|
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 summaryOptions: Array<{ value: AutomationFilter; label: string; count: number }> = [
|
|
{ value: "all", label: tx("settings.automations.filters.all", "All"), count: jobs.length },
|
|
{ value: "active", label: tx("settings.automations.filters.active", "Active"), count: activeCount },
|
|
{ value: "paused", label: tx("settings.automations.filters.paused", "Paused"), count: pausedCount },
|
|
{ value: "failed", label: tx("settings.automations.filters.failed", "Needs attention"), count: failedCount },
|
|
{ value: "system", label: tx("settings.automations.filters.system", "System"), count: systemCount },
|
|
];
|
|
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>;
|
|
const selectedJob = filtered.find((job) => job.id === selectedJobId) ?? filtered[0] ?? null;
|
|
|
|
useEffect(() => {
|
|
if (!filtered.length) {
|
|
if (selectedJobId !== null) setSelectedJobId(null);
|
|
return;
|
|
}
|
|
if (!selectedJobId || !filtered.some((job) => job.id === selectedJobId)) {
|
|
setSelectedJobId(filtered[0].id);
|
|
}
|
|
}, [filtered, selectedJobId]);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{jobs.length ? (
|
|
<section className="shrink-0">
|
|
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
|
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
|
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-floating bg-muted p-1">
|
|
{summaryOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => onFilterChange(option.value)}
|
|
className={cn(
|
|
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-control px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
|
filter === option.value && "bg-background text-foreground",
|
|
automationFilterToneClass(option.value, option.count, filter === option.value),
|
|
)}
|
|
>
|
|
<span>{option.label}</span>
|
|
<span
|
|
className={cn(
|
|
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
|
|
automationFilterCountClass(option.value, option.count),
|
|
)}
|
|
>
|
|
{option.count}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
|
<div className="relative min-w-0">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
|
<Input
|
|
value={query}
|
|
onChange={(event) => onQueryChange(event.target.value)}
|
|
placeholder={tx(
|
|
"settings.automations.search",
|
|
"Search task, message, linked chat, or schedule",
|
|
)}
|
|
className={cn(
|
|
"h-9 w-full rounded-control pl-9 text-[13px]",
|
|
SETTINGS_SEARCH_INPUT_CLASS,
|
|
)}
|
|
/>
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-control border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
|
|
>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<div className="flex items-center gap-2 rounded-floating border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
|
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden />
|
|
<span>{error}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{loading && !payload ? (
|
|
<div className="flex h-44 items-center justify-center rounded-panel bg-settings-surface text-[13px] text-muted-foreground">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
|
{tx("settings.automations.loading", "Loading automations...")}
|
|
</div>
|
|
) : filtered.length && selectedJob ? (
|
|
<section className="grid min-h-0 overflow-hidden rounded-panel bg-settings-surface xl:grid-cols-[minmax(16rem,18rem)_minmax(0,1fr)] xl:items-stretch">
|
|
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/35 bg-settings-surface xl:border-b-0 xl:border-r">
|
|
<div className="flex shrink-0 items-center justify-between gap-3 px-4 py-3">
|
|
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
|
{tx("settings.automations.queue", "Queue")}
|
|
</h2>
|
|
<span className="rounded-full bg-orange-100/60 px-2 py-0.5 text-[11px] text-orange-800/70 tabular-nums dark:bg-orange-300/10 dark:text-orange-200/75">
|
|
{filtered.length}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="max-h-[28rem] space-y-1 overflow-y-auto overscroll-contain px-2 pb-2 xl:max-h-[calc(100vh-24rem)]"
|
|
role="list"
|
|
aria-label={tx("settings.automations.queue", "Queue")}
|
|
>
|
|
{filtered.map((job) => (
|
|
<AutomationListItem
|
|
key={job.id}
|
|
job={job}
|
|
locale={locale}
|
|
selected={job.id === selectedJob.id}
|
|
onSelect={() => setSelectedJobId(job.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
<AutomationDetailPanel
|
|
job={selectedJob}
|
|
locale={locale}
|
|
actionKey={actionKey}
|
|
onAction={onAction}
|
|
onRequestEdit={onRequestEdit}
|
|
onRequestDelete={onRequestDelete}
|
|
/>
|
|
</section>
|
|
) : (
|
|
<div className="rounded-panel bg-settings-surface px-5 py-12 text-center text-[13px] text-muted-foreground">
|
|
<div>
|
|
{jobs.length
|
|
? 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 automations in a chat so they keep the right context.",
|
|
)}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="mt-4 rounded-full"
|
|
onClick={onBackToChat}
|
|
>
|
|
{tx("settings.automations.emptyAction", "Open a chat")}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="mt-4 rounded-full"
|
|
onClick={() => {
|
|
onQueryChange("");
|
|
onFilterChange("all");
|
|
}}
|
|
>
|
|
{tx("settings.automations.clearFilters", "Clear filters")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutomationListItem({
|
|
job,
|
|
locale,
|
|
selected,
|
|
onSelect,
|
|
}: {
|
|
job: SessionAutomationJob;
|
|
locale: string;
|
|
selected: boolean;
|
|
onSelect: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
const status = automationStatus(job, tx);
|
|
const origin = automationOriginLabel(job, tx);
|
|
const nextRun = formatAutomationNext(job, tx);
|
|
const summary = automationSummary(job, tx);
|
|
|
|
return (
|
|
<div role="listitem">
|
|
<button
|
|
type="button"
|
|
aria-pressed={selected}
|
|
onClick={onSelect}
|
|
className={cn(
|
|
"group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-floating px-3 py-3.5 text-left transition-colors",
|
|
selected
|
|
? "bg-background/80 text-foreground"
|
|
: "text-muted-foreground hover:bg-background/55 hover:text-foreground",
|
|
)}
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="flex min-w-0 items-center gap-2.5">
|
|
<span
|
|
className={cn("h-2 w-2 shrink-0 rounded-full", automationStatusDotClass(job))}
|
|
aria-hidden
|
|
/>
|
|
<span className="truncate text-[13.5px] font-medium text-foreground">
|
|
{job.name || job.id}
|
|
</span>
|
|
</span>
|
|
<span className="mt-1.5 line-clamp-2 text-[12px] leading-5 text-muted-foreground">
|
|
{summary}
|
|
</span>
|
|
<span className="mt-2.5 flex min-w-0 items-center gap-2 text-[11.5px] leading-none text-muted-foreground">
|
|
<span className="truncate" title={formatAutomationNextTitle(job, locale, tx)}>
|
|
{nextRun}
|
|
</span>
|
|
<span className="h-1 w-1 shrink-0 rounded-full bg-muted-foreground/35" aria-hidden />
|
|
<span className="truncate">{origin}</span>
|
|
</span>
|
|
</span>
|
|
<span className="flex shrink-0 flex-col items-end gap-2 pt-0.5">
|
|
<span className="rounded-full bg-background/70 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
{status.label}
|
|
</span>
|
|
{job.delete_after_run ? (
|
|
<span className="rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.oneShot", "One-time")}
|
|
</span>
|
|
) : null}
|
|
<ChevronRight
|
|
className={cn(
|
|
"h-3.5 w-3.5 text-muted-foreground/55 transition-opacity",
|
|
selected ? "opacity-100" : "opacity-0 group-hover:opacity-70",
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutomationDetailPanel({
|
|
job,
|
|
locale,
|
|
actionKey,
|
|
onAction,
|
|
onRequestEdit,
|
|
onRequestDelete,
|
|
}: {
|
|
job: SessionAutomationJob;
|
|
locale: string;
|
|
actionKey: string | null;
|
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
const status = automationStatus(job, tx);
|
|
const origin = automationOriginLabel(job, tx);
|
|
const originHref = job.origin?.channel === "websocket" && job.origin.session_key
|
|
? `#/chat/${encodeURIComponent(job.origin.session_key)}`
|
|
: null;
|
|
const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
|
|
const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
|
|
const localTrigger = isLocalTriggerAutomation(job);
|
|
const triggerCommand = automationTriggerCommand(job);
|
|
const message = automationDetailText(job, tx);
|
|
const messageLabel = localTrigger
|
|
? tx("settings.automations.fields.command", "Command")
|
|
: tx("settings.automations.fields.message", "Message");
|
|
const schedule = formatAutomationSchedule(job, locale, tx);
|
|
const [messageExpanded, setMessageExpanded] = useState(false);
|
|
const [commandCopied, setCommandCopied] = useState(false);
|
|
const messageNeedsExpansion = automationMessageNeedsExpansion(message);
|
|
|
|
useEffect(() => {
|
|
setMessageExpanded(false);
|
|
setCommandCopied(false);
|
|
}, [job.id]);
|
|
|
|
return (
|
|
<article className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-settings-surface">
|
|
<div className="shrink-0 border-b border-border/35 px-4 py-3.5 dark:border-white/10 sm:px-5">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="min-w-0">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<h3 className="min-w-0 truncate text-[18px] font-medium leading-7 text-foreground">
|
|
{job.name || job.id}
|
|
</h3>
|
|
<AutomationStatusBadge tone={status.tone}>{status.label}</AutomationStatusBadge>
|
|
{job.delete_after_run ? (
|
|
<AutomationStatusBadge>{tx("settings.automations.oneShot", "One-time")}</AutomationStatusBadge>
|
|
) : null}
|
|
</div>
|
|
<p className="mt-1 truncate text-[12.5px] leading-5 text-muted-foreground">
|
|
{schedule} · {origin}
|
|
</p>
|
|
</div>
|
|
<AutomationActionGroup
|
|
job={job}
|
|
actionKey={actionKey}
|
|
onAction={onAction}
|
|
onRequestEdit={onRequestEdit}
|
|
onRequestDelete={onRequestDelete}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid min-h-0 min-w-0 flex-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_14.5rem]">
|
|
<div className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-5">
|
|
<section className="rounded-floating bg-background/55 px-4 py-3.5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
|
{messageLabel}
|
|
</div>
|
|
{localTrigger && triggerCommand ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 shrink-0 rounded-full px-2 text-[11.5px]"
|
|
onClick={() => {
|
|
void copyTextToClipboard(triggerCommand).then((ok) => {
|
|
if (ok) setCommandCopied(true);
|
|
});
|
|
}}
|
|
>
|
|
{commandCopied ? (
|
|
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
) : (
|
|
<Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
)}
|
|
{commandCopied
|
|
? tx("settings.automations.commandCopied", "Copied")
|
|
: tx("settings.automations.copyCommand", "Copy")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"mt-3 whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85",
|
|
localTrigger && "font-mono text-[12.5px]",
|
|
!messageExpanded && messageNeedsExpansion && "line-clamp-6",
|
|
)}
|
|
>
|
|
{message}
|
|
</div>
|
|
{messageNeedsExpansion ? (
|
|
<button
|
|
type="button"
|
|
className="mt-3 inline-flex text-[12px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
onClick={() => setMessageExpanded((value) => !value)}
|
|
>
|
|
{messageExpanded
|
|
? tx("settings.automations.message.showLess", "Show less")
|
|
: tx("settings.automations.message.showMore", "Show full message")}
|
|
</button>
|
|
) : null}
|
|
</section>
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<AutomationDetail
|
|
label={tx("settings.automations.labels.next", "Next")}
|
|
title={formatAutomationNextTitle(job, locale, tx)}
|
|
>
|
|
{formatAutomationNext(job, tx)}
|
|
</AutomationDetail>
|
|
<AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}>
|
|
{originHref ? (
|
|
<a
|
|
className="inline-flex max-w-full items-center gap-1 text-foreground/80 underline-offset-2 hover:underline"
|
|
href={originHref}
|
|
>
|
|
<span className="truncate">{origin}</span>
|
|
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
|
|
</a>
|
|
) : (
|
|
origin
|
|
)}
|
|
</AutomationDetail>
|
|
</div>
|
|
|
|
{job.state.last_error ? (
|
|
<div className="rounded-floating border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
|
|
{job.state.last_error}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<aside className="min-h-0 overflow-y-auto overscroll-contain border-t border-border/35 bg-settings-surface p-4 text-[12px] text-muted-foreground lg:border-l lg:border-t-0">
|
|
<div className="grid gap-3">
|
|
<AutomationDetail
|
|
label={tx("settings.automations.labels.schedule", "Schedule")}
|
|
title={schedule}
|
|
>
|
|
{schedule}
|
|
</AutomationDetail>
|
|
<div className="rounded-floating bg-background/55 p-3">
|
|
<div className="grid gap-3">
|
|
{created ? (
|
|
<div>
|
|
<div className="text-[11px] leading-none text-muted-foreground/75">
|
|
{tx("settings.automations.labels.created", "Created")}
|
|
</div>
|
|
<div className="mt-1.5 text-[12.5px] leading-5 text-foreground/80">{created}</div>
|
|
</div>
|
|
) : null}
|
|
{updated ? (
|
|
<div>
|
|
<div className="text-[11px] leading-none text-muted-foreground/75">
|
|
{tx("settings.automations.labels.updated", "Updated")}
|
|
</div>
|
|
<div className="mt-1.5 text-[12.5px] leading-5 text-foreground/80">{updated}</div>
|
|
</div>
|
|
) : null}
|
|
<div>
|
|
<div className="text-[11px] leading-none text-muted-foreground/75">ID</div>
|
|
<div className="mt-1.5 break-all font-mono text-[11.5px] leading-5 text-foreground/70">
|
|
{job.id}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function AutomationActionGroup({
|
|
job,
|
|
actionKey,
|
|
onAction,
|
|
onRequestEdit,
|
|
onRequestDelete,
|
|
}: {
|
|
job: SessionAutomationJob;
|
|
actionKey: string | null;
|
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
const canManage = !job.protected;
|
|
const hasLinkedChat = Boolean(job.origin);
|
|
const localTrigger = isLocalTriggerAutomation(job);
|
|
const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending && !localTrigger;
|
|
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
|
|
const canToggle = canManage && (job.enabled || hasLinkedChat);
|
|
const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
|
|
|
|
if (!canManage) {
|
|
return (
|
|
<span className="inline-flex h-9 items-center rounded-full bg-muted px-3 text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.protected", "Protected")}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-1 rounded-full bg-background/65 p-1">
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.automations.edit", "Edit")}
|
|
disabled={Boolean(actionKey)}
|
|
onClick={() => onRequestEdit(job)}
|
|
>
|
|
<Pencil className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
{!localTrigger ? (
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.automations.runNow", "Run now")}
|
|
busy={actionKey === `run:${job.id}`}
|
|
disabled={!canRun}
|
|
onClick={() => void onAction("run", job)}
|
|
>
|
|
<PlayCircle className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
) : null}
|
|
<AppsActionButton
|
|
ariaLabel={
|
|
job.enabled
|
|
? tx("settings.automations.pause", "Pause")
|
|
: tx("settings.automations.resume", "Resume")
|
|
}
|
|
busy={toggleBusy}
|
|
disabled={!canToggle}
|
|
onClick={() => void onAction(toggleAction, job)}
|
|
>
|
|
{job.enabled ? (
|
|
<PauseCircle className="h-4 w-4" aria-hidden />
|
|
) : (
|
|
<PlayCircle className="h-4 w-4" aria-hidden />
|
|
)}
|
|
</AppsActionButton>
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.automations.delete", "Delete")}
|
|
tone="danger"
|
|
disabled={Boolean(actionKey)}
|
|
onClick={() => onRequestDelete(job)}
|
|
>
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutomationStatusBadge({
|
|
tone = "neutral",
|
|
children,
|
|
}: {
|
|
tone?: "neutral" | "success" | "warning";
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"inline-flex h-6 items-center rounded-full px-2.5 text-[11.5px] font-medium",
|
|
tone === "success" &&
|
|
"bg-orange-100/72 text-orange-800 dark:bg-orange-300/12 dark:text-orange-200",
|
|
tone === "warning" &&
|
|
"bg-amber-100/80 text-amber-800 dark:bg-amber-300/14 dark:text-amber-200",
|
|
tone === "neutral" &&
|
|
"bg-white/64 text-muted-foreground dark:bg-background/35 dark:text-muted-foreground",
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function automationMessageNeedsExpansion(message: string): boolean {
|
|
return message.length > 360 || message.split(/\r?\n/).length > 6;
|
|
}
|
|
|
|
function AutomationDetail({
|
|
label,
|
|
title,
|
|
secondary,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
title?: string;
|
|
secondary?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="min-w-0 rounded-floating bg-background/55 px-3 py-3">
|
|
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
|
{label}
|
|
</div>
|
|
<div className="mt-1.5 min-w-0">
|
|
<div className="line-clamp-2 text-[13px] leading-5 text-foreground/85" title={title}>
|
|
{children}
|
|
</div>
|
|
{secondary ? (
|
|
<div className="mt-0.5 truncate text-[11.5px] leading-4 text-muted-foreground" title={title}>
|
|
{secondary}
|
|
</div>
|
|
) : null}
|
|
</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;
|
|
};
|
|
type AutomationScheduleUpdate = NonNullable<AutomationUpdatePayload["schedule"]>;
|
|
|
|
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 },
|
|
];
|
|
|
|
export 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));
|
|
const localTrigger = isLocalTriggerAutomation(job);
|
|
|
|
useEffect(() => {
|
|
setDraft(automationDraftFromJob(job));
|
|
}, [job]);
|
|
|
|
const validation = automationEditDraftError(draft, job, 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, job);
|
|
if (!job || typeof payload === "string") return;
|
|
void onSave(job, payload);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
|
{job ? (
|
|
<DialogContent
|
|
aria-describedby={undefined}
|
|
className="w-[min(calc(100vw-2rem),34rem)]"
|
|
>
|
|
<form className="space-y-5" onSubmit={submit}>
|
|
<DialogHeader>
|
|
<DialogTitle>{tx("settings.automations.editTitle", "Edit automation")}</DialogTitle>
|
|
</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 }))}
|
|
/>
|
|
</label>
|
|
|
|
{!localTrigger ? (
|
|
<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-[160px] resize-none text-[13px] leading-5"
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{!localTrigger ? (
|
|
<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>
|
|
) : null}
|
|
|
|
{!localTrigger && 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 }))
|
|
}
|
|
/>
|
|
</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={cn(
|
|
"h-10 w-full rounded-control border border-input bg-background px-3 text-[13px] text-foreground transition-colors",
|
|
formControlFocusClassName,
|
|
)}
|
|
>
|
|
{AUTOMATION_EVERY_UNITS.map((unit) => (
|
|
<option key={unit.value} value={unit.value}>
|
|
{unitLabels[unit.value]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{!localTrigger && 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="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="text-[13px]"
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{!localTrigger && 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 }))}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{validation ? (
|
|
<div className="rounded-control 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}
|
|
>
|
|
{tx("settings.automations.cancel", "Cancel")}
|
|
</Button>
|
|
<Button type="submit" disabled={Boolean(validation) || saving}>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
export function AutomationDeleteDialog({
|
|
job,
|
|
deleting,
|
|
onOpenChange,
|
|
onConfirm,
|
|
}: {
|
|
job: SessionAutomationJob | null;
|
|
deleting: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onConfirm: (job: SessionAutomationJob) => void | Promise<void>;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
return (
|
|
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
|
<DialogContent className="w-[min(calc(100vw-2rem),26rem)]">
|
|
<DialogHeader>
|
|
<DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle>
|
|
<DialogDescription>
|
|
{tx(
|
|
"settings.automations.deleteDescription",
|
|
"This removes {{name}} from automations. Past chat messages stay in the session.",
|
|
{ name: job?.name || job?.id || "" },
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={deleting}
|
|
>
|
|
{tx("settings.automations.cancel", "Cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => job && void onConfirm(job)}
|
|
disabled={!job || deleting}
|
|
>
|
|
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
{tx("settings.automations.delete", "Delete")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function isLocalTriggerAutomation(job: SessionAutomationJob | null): boolean {
|
|
if (!job) return false;
|
|
return job.kind === "local_trigger"
|
|
|| job.payload.kind === "local_trigger"
|
|
|| job.schedule.kind === "local";
|
|
}
|
|
|
|
function automationTriggerCommand(job: SessionAutomationJob): string {
|
|
return job.trigger?.command || job.payload.command || job.payload.message || "";
|
|
}
|
|
|
|
function automationSummary(
|
|
job: SessionAutomationJob,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (isLocalTriggerAutomation(job)) {
|
|
return automationTriggerCommand(job) || tx("settings.automations.localTrigger", "Local trigger");
|
|
}
|
|
return job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
|
|
}
|
|
|
|
function automationDetailText(
|
|
job: SessionAutomationJob,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
return automationSummary(job, tx);
|
|
}
|
|
|
|
function automationNeedsAttention(job: SessionAutomationJob): boolean {
|
|
return job.state.last_status === "error";
|
|
}
|
|
|
|
function automationStatusKey(
|
|
job: SessionAutomationJob,
|
|
): "active" | "running" | "paused" | "failed" | "system" | "completed" | "idle" {
|
|
if (job.protected) return "system";
|
|
if (job.state.pending) return "running";
|
|
if (!job.enabled) return "paused";
|
|
if (job.state.last_status === "error") return "failed";
|
|
if (isLocalTriggerAutomation(job)) return "active";
|
|
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,
|
|
job: SessionAutomationJob | null,
|
|
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 (isLocalTriggerAutomation(job)) return null;
|
|
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() && automationScheduleChanged(draft, job)) {
|
|
return tx("settings.automations.validation.futureRequired", "Run time must be in the future.");
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function automationUpdatePayloadFromDraft(
|
|
draft: AutomationEditDraft,
|
|
job: SessionAutomationJob | null,
|
|
): AutomationUpdatePayload | string {
|
|
const name = draft.name.trim();
|
|
if (isLocalTriggerAutomation(job)) {
|
|
if (!name) return "invalid";
|
|
return { name };
|
|
}
|
|
const message = draft.message.trim();
|
|
if (!name || !message) return "invalid";
|
|
const payload: AutomationUpdatePayload = { name, message };
|
|
const schedule = automationSchedulePayloadFromDraft(draft);
|
|
if (typeof schedule === "string") return schedule;
|
|
if (automationScheduleChanged(draft, job, schedule)) {
|
|
payload.schedule = schedule;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function automationSchedulePayloadFromDraft(draft: AutomationEditDraft): AutomationScheduleUpdate | string {
|
|
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";
|
|
return { kind: "every", every_ms: value * unit.ms };
|
|
} else if (draft.scheduleKind === "cron") {
|
|
const expr = draft.cronExpr.trim();
|
|
if (!expr) return "invalid";
|
|
return { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
|
|
} else {
|
|
const atMs = new Date(draft.atLocal).getTime();
|
|
if (!Number.isFinite(atMs)) return "invalid";
|
|
return { kind: "at", at_ms: atMs };
|
|
}
|
|
}
|
|
|
|
function automationScheduleChanged(
|
|
draft: AutomationEditDraft,
|
|
job: SessionAutomationJob | null,
|
|
schedule: AutomationScheduleUpdate | string = automationSchedulePayloadFromDraft(draft),
|
|
): boolean {
|
|
if (!job || typeof schedule === "string") return true;
|
|
if (schedule.kind !== job.schedule.kind) return true;
|
|
if (schedule.kind === "every") return schedule.every_ms !== job.schedule.every_ms;
|
|
if (schedule.kind === "cron") {
|
|
return schedule.expr !== (job.schedule.expr ?? "") || (schedule.tz ?? null) !== (job.schedule.tz ?? null);
|
|
}
|
|
return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN);
|
|
}
|
|
|
|
type AutomationSearchField = "id" | "name" | "message" | "chat" | "cron" | "schedule" | "status";
|
|
|
|
interface AutomationSearchToken {
|
|
field: AutomationSearchField | null;
|
|
value: string;
|
|
}
|
|
|
|
const AUTOMATION_SEARCH_FIELDS = new Set<AutomationSearchField>([
|
|
"id",
|
|
"name",
|
|
"message",
|
|
"chat",
|
|
"cron",
|
|
"schedule",
|
|
"status",
|
|
]);
|
|
|
|
const HOST_AUTOMATION_CHANNEL_LABELS: Record<string, string> = {
|
|
api: "API",
|
|
cli: "CLI",
|
|
};
|
|
|
|
function parseAutomationSearchQuery(query: string): AutomationSearchToken[] {
|
|
return (query.match(/[^\s:]+:"[^"]+"|"[^"]+"|\S+/g) ?? [])
|
|
.map((rawPart): AutomationSearchToken | null => {
|
|
const part = trimAutomationSearchValue(rawPart);
|
|
if (!part) return null;
|
|
const fieldMatch = part.match(/^([A-Za-z]+):(.*)$/);
|
|
if (!fieldMatch) return { field: null, value: part.toLowerCase() };
|
|
const field = fieldMatch[1].toLowerCase() as AutomationSearchField;
|
|
const value = trimAutomationSearchValue(fieldMatch[2]).toLowerCase();
|
|
if (!value) return null;
|
|
return AUTOMATION_SEARCH_FIELDS.has(field)
|
|
? { field, value }
|
|
: { field: null, value: part.toLowerCase() };
|
|
})
|
|
.filter((token): token is AutomationSearchToken => Boolean(token));
|
|
}
|
|
|
|
function trimAutomationSearchValue(value: string): string {
|
|
return value.trim().replace(/^"|"$/g, "").trim();
|
|
}
|
|
|
|
function automationMatchesSearch(job: SessionAutomationJob, tokens: AutomationSearchToken[]): boolean {
|
|
return tokens.every((token) => automationSearchText(job, token.field).includes(token.value));
|
|
}
|
|
|
|
function automationSearchText(job: SessionAutomationJob, field: AutomationSearchField | null = null): string {
|
|
return automationSearchParts(job, field)
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function automationSearchParts(
|
|
job: SessionAutomationJob,
|
|
field: AutomationSearchField | null,
|
|
): Array<string | number | null | undefined> {
|
|
const originParts = automationOriginSearchParts(job);
|
|
const scheduleParts = automationScheduleSearchParts(job);
|
|
if (field === "id") return [job.id];
|
|
if (field === "name") return [job.name, job.id];
|
|
if (field === "message") return [job.payload.message, job.payload.command, job.trigger?.command];
|
|
if (field === "chat") return originParts;
|
|
if (field === "cron" || field === "schedule") return scheduleParts;
|
|
if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"];
|
|
return [
|
|
job.id,
|
|
job.name,
|
|
job.payload.message,
|
|
job.payload.command,
|
|
job.trigger?.command,
|
|
isLocalTriggerAutomation(job) ? "trigger local" : null,
|
|
...scheduleParts,
|
|
automationStatusKey(job),
|
|
...originParts,
|
|
];
|
|
}
|
|
|
|
function automationOriginSearchParts(job: SessionAutomationJob): Array<string | null | undefined> {
|
|
const origin = job.origin;
|
|
if (!origin) return [];
|
|
const channel = origin.channel.trim().toLowerCase();
|
|
return [
|
|
origin.session_key,
|
|
origin.title,
|
|
origin.preview,
|
|
origin.channel,
|
|
automationChannelDisplayName(channel),
|
|
];
|
|
}
|
|
|
|
function automationScheduleSearchParts(job: SessionAutomationJob): Array<string | number | null | undefined> {
|
|
const schedule = job.schedule;
|
|
const parts: Array<string | number | null | undefined> = [
|
|
schedule.kind,
|
|
schedule.expr,
|
|
schedule.tz,
|
|
schedule.every_ms,
|
|
schedule.at_ms,
|
|
];
|
|
if (schedule.kind === "cron" && schedule.expr) {
|
|
parts.push(...automationCronSearchParts(schedule.expr));
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function automationCronSearchParts(expr: string): string[] {
|
|
const parts = expr.trim().split(/\s+/);
|
|
if (parts.length !== 5) return [];
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
|
|
const numericMinute = cronNumericToken(minute, 59);
|
|
const numericHour = cronNumericToken(hour, 23);
|
|
if (numericMinute === null) return [];
|
|
const paddedMinute = String(numericMinute).padStart(2, "0");
|
|
|
|
if (numericHour !== null) {
|
|
const time = `${String(numericHour).padStart(2, "0")}:${paddedMinute}`;
|
|
return [time, `:${paddedMinute}`];
|
|
}
|
|
|
|
if (everyDay && hour === "*") {
|
|
return [`:${paddedMinute}`, `hourly at :${paddedMinute}`];
|
|
}
|
|
|
|
const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
|
|
if (!everyDay || !range) return [];
|
|
const start = Number(range[1]);
|
|
const end = Number(range[2]);
|
|
if (start > 23 || end > 23) return [];
|
|
const paddedRange = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`;
|
|
const rawRange = `${start}-${end}`;
|
|
return [
|
|
paddedRange,
|
|
rawRange,
|
|
`:${paddedMinute}`,
|
|
`${paddedRange} at :${paddedMinute}`,
|
|
`hourly ${paddedRange} at :${paddedMinute}`,
|
|
];
|
|
}
|
|
|
|
function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
|
|
const status = automationStatusKey(job);
|
|
if (filter === "active") return status === "active" || status === "running";
|
|
if (filter === "paused") return status === "paused";
|
|
if (filter === "failed") return automationNeedsAttention(job);
|
|
if (filter === "system") return Boolean(job.protected);
|
|
return true;
|
|
}
|
|
|
|
const AUTOMATION_FILTER_TONES: Partial<
|
|
Record<AutomationFilter, { text: string; selectedText: string; count: string }>
|
|
> = {
|
|
active: {
|
|
text: "text-emerald-600 dark:text-emerald-400",
|
|
selectedText: "text-emerald-700 dark:text-emerald-300",
|
|
count: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
|
},
|
|
paused: {
|
|
text: "text-amber-600 dark:text-amber-400",
|
|
selectedText: "text-amber-700 dark:text-amber-300",
|
|
count: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
|
},
|
|
failed: {
|
|
text: "text-rose-600 dark:text-rose-400",
|
|
selectedText: "text-rose-700 dark:text-rose-300",
|
|
count: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
|
|
},
|
|
system: {
|
|
text: "text-sky-600 dark:text-sky-400",
|
|
selectedText: "text-sky-700 dark:text-sky-300",
|
|
count: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
|
},
|
|
};
|
|
|
|
function automationFilterToneClass(value: AutomationFilter, count: number, selected: boolean): string {
|
|
const tone = AUTOMATION_FILTER_TONES[value];
|
|
if (count <= 0 || !tone) return "";
|
|
return selected ? tone.selectedText : tone.text;
|
|
}
|
|
|
|
function automationFilterCountClass(value: AutomationFilter, count: number): string {
|
|
const tone = AUTOMATION_FILTER_TONES[value];
|
|
return count > 0 && tone ? tone.count : "";
|
|
}
|
|
|
|
function automationStatus(
|
|
job: SessionAutomationJob,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): { label: string; tone: "neutral" | "success" | "warning" } {
|
|
const status = automationStatusKey(job);
|
|
if (status === "system") return { label: tx("settings.automations.status.system", "System"), tone: "neutral" };
|
|
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" };
|
|
}
|
|
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" };
|
|
}
|
|
|
|
function automationOriginLabel(
|
|
job: SessionAutomationJob,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (job.protected) return tx("settings.automations.origin.system", "System");
|
|
const origin = job.origin;
|
|
if (!origin) return tx("settings.automations.origin.unknown", "No linked chat");
|
|
if (origin.channel !== "websocket") return automationChannelLabel(origin.channel, tx);
|
|
return origin.title || origin.preview || origin.session_key || automationChannelLabel(origin.channel, tx);
|
|
}
|
|
|
|
function automationChannelLabel(
|
|
channel: string,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
const key = channel.trim().toLowerCase();
|
|
const displayName = automationChannelDisplayName(key);
|
|
return displayName
|
|
? tx(`settings.automations.channels.${key}`, displayName)
|
|
: channel;
|
|
}
|
|
|
|
function automationChannelDisplayName(channel: string): string | undefined {
|
|
const key = channel.trim().toLowerCase();
|
|
return channelUiPresentation(key)?.displayName ?? HOST_AUTOMATION_CHANNEL_LABELS[key];
|
|
}
|
|
|
|
function formatAutomationSchedule(
|
|
job: SessionAutomationJob,
|
|
locale: string,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (job.schedule.kind === "at" && job.schedule.at_ms) {
|
|
return tx("settings.automations.schedule.at", "At {{time}}", {
|
|
time: fmtDateTime(job.schedule.at_ms, locale),
|
|
});
|
|
}
|
|
if (job.schedule.kind === "every" && job.schedule.every_ms) {
|
|
return tx("settings.automations.schedule.every", "Every {{duration}}", {
|
|
duration: formatAutomationInterval(job.schedule.every_ms, locale),
|
|
});
|
|
}
|
|
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
|
|
? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", {
|
|
expr: job.schedule.expr,
|
|
tz: job.schedule.tz,
|
|
})
|
|
: tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr });
|
|
}
|
|
if (isLocalTriggerAutomation(job)) {
|
|
return tx("settings.automations.schedule.local", "Local trigger");
|
|
}
|
|
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(
|
|
job: SessionAutomationJob,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (!job.enabled) return tx("settings.automations.next.paused", "Paused");
|
|
if (job.state.pending) return tx("settings.automations.next.pending", "Running now");
|
|
if (isLocalTriggerAutomation(job)) {
|
|
return tx("settings.automations.next.local", "Waiting for trigger");
|
|
}
|
|
if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
|
|
return relativeTime(job.state.next_run_at_ms);
|
|
}
|
|
|
|
function 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 automationStatusDotClass(job: SessionAutomationJob): string {
|
|
const status = automationStatusKey(job);
|
|
if (status === "active" || status === "running") return "bg-orange-500";
|
|
if (status === "failed") return "bg-amber-500";
|
|
return "bg-muted-foreground/45";
|
|
}
|
|
|
|
function formatAutomationUnit(
|
|
value: number,
|
|
unit: Intl.NumberFormatOptions["unit"],
|
|
locale: string,
|
|
maximumFractionDigits = 0,
|
|
): string {
|
|
return new Intl.NumberFormat(locale, {
|
|
style: "unit",
|
|
unit,
|
|
unitDisplay: "long",
|
|
maximumFractionDigits,
|
|
}).format(value);
|
|
}
|
|
|
|
function formatAutomationInterval(ms: number, locale: string): string {
|
|
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
|
|
["day", 86_400_000],
|
|
["hour", 3_600_000],
|
|
["minute", 60_000],
|
|
["second", 1000],
|
|
];
|
|
for (const [unit, size] of units) {
|
|
if (ms >= size && ms % size === 0) return formatAutomationUnit(ms / size, unit, locale);
|
|
}
|
|
const fallbackUnit = ms < 60_000 ? "second" : "minute";
|
|
const fallbackSize = fallbackUnit === "second" ? 1000 : 60_000;
|
|
return formatAutomationUnit(ms / fallbackSize, fallbackUnit, locale, 1);
|
|
}
|