fix(webui): align automation toolbar controls

This commit is contained in:
chengyongru
2026-06-16 00:59:42 +08:00
parent 8f1fe7337c
commit f56a73b2d3
11 changed files with 175 additions and 49 deletions
+154 -39
View File
@@ -3438,10 +3438,10 @@ function AutomationsSettings({
const locale = i18n.resolvedLanguage || i18n.language; const locale = i18n.resolvedLanguage || i18n.language;
const [selectedJobId, setSelectedJobId] = useState<string | null>(null); const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase(); const searchTokens = parseAutomationSearchQuery(query);
return sortAutomationJobs(jobs, sort) return sortAutomationJobs(jobs, sort)
.filter((job) => automationMatchesFilter(job, filter)) .filter((job) => automationMatchesFilter(job, filter))
.filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery)); .filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens));
}, [filter, jobs, query, sort]); }, [filter, jobs, query, sort]);
const activeCount = jobs.filter((job) => { const activeCount = jobs.filter((job) => {
const key = automationStatusKey(job); const key = automationStatusKey(job);
@@ -3478,16 +3478,16 @@ function AutomationsSettings({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<section className="rounded-[24px] border border-border/45 bg-card/80 p-3 shadow-[0_22px_70px_rgba(15,23,42,0.055)] backdrop-blur-xl sm:p-4"> <section className="rounded-[24px] border border-border/45 bg-card/80 p-3 shadow-[0_22px_70px_rgba(15,23,42,0.055)] backdrop-blur-xl sm:p-4">
<div className="flex flex-col gap-3"> <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="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="inline-flex min-w-max items-center gap-1 rounded-[16px] bg-muted/50 p-1"> <div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[16px] bg-muted/50 p-1">
{summaryOptions.map((option) => ( {summaryOptions.map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => onFilterChange(option.value)} onClick={() => onFilterChange(option.value)}
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-[12px] px-3 text-[12px] font-medium text-muted-foreground transition-colors", "inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[12px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
filter === option.value && "bg-background text-foreground shadow-sm", filter === option.value && "bg-background text-foreground shadow-sm",
)} )}
> >
@@ -3506,7 +3506,7 @@ function AutomationsSettings({
<Input <Input
value={query} value={query}
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.automations.search", "Search automations")} placeholder={tx("settings.automations.search", "name:quiz chat:WeChat cron:09-23")}
className="h-9 w-full rounded-[13px] border-border/45 bg-background/85 pl-9 text-[13px] shadow-sm" className="h-9 w-full rounded-[13px] border-border/45 bg-background/85 pl-9 text-[13px] shadow-sm"
/> />
</div> </div>
@@ -4364,24 +4364,156 @@ function automationScheduleChanged(
return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN); return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN);
} }
function automationSearchText(job: SessionAutomationJob): string { type AutomationSearchField = "id" | "name" | "message" | "chat" | "cron" | "schedule" | "status";
const originText = job.origin
? job.origin.channel === "websocket" interface AutomationSearchToken {
? [job.origin.session_key, job.origin.title, job.origin.preview] field: AutomationSearchField | null;
: [job.origin.channel] value: string;
: []; }
const AUTOMATION_SEARCH_FIELDS = new Set<AutomationSearchField>([
"id",
"name",
"message",
"chat",
"cron",
"schedule",
"status",
]);
const AUTOMATION_CHANNEL_LABELS: Record<string, string> = {
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",
};
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];
if (field === "chat") return originParts;
if (field === "cron" || field === "schedule") return scheduleParts;
if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"];
return [ return [
job.id, job.id,
job.name, job.name,
job.payload.message, job.payload.message,
job.schedule.kind, ...scheduleParts,
job.schedule.expr, automationStatusKey(job),
job.schedule.tz, ...originParts,
...originText, ];
] }
.filter(Boolean)
.join(" ") function automationOriginSearchParts(job: SessionAutomationJob): Array<string | null | undefined> {
.toLowerCase(); const origin = job.origin;
if (!origin) return [];
const channel = origin.channel.trim().toLowerCase();
return [
origin.session_key,
origin.title,
origin.preview,
origin.channel,
AUTOMATION_CHANNEL_LABELS[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 { function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
@@ -4431,25 +4563,8 @@ function automationChannelLabel(
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string, tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string { ): string {
const key = channel.trim().toLowerCase(); const key = channel.trim().toLowerCase();
const labels: Record<string, string> = { return AUTOMATION_CHANNEL_LABELS[key]
api: "API", ? tx(`settings.automations.channels.${key}`, AUTOMATION_CHANNEL_LABELS[key])
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",
};
return labels[key]
? tx(`settings.automations.channels.${key}`, labels[key])
: channel; : channel;
} }
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "Updated", "updated": "Updated",
"name": "Name" "name": "Name"
}, },
"search": "Search automations", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "Queue", "queue": "Queue",
"loading": "Loading automations...", "loading": "Loading automations...",
"noMatches": "No automations match this view.", "noMatches": "No automations match this view.",
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "Actualizada", "updated": "Actualizada",
"name": "Nombre" "name": "Nombre"
}, },
"search": "Buscar automatizaciones", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "Cola", "queue": "Cola",
"loading": "Cargando automatizaciones...", "loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.", "noMatches": "No hay automatizaciones que coincidan con esta vista.",
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "Mise à jour", "updated": "Mise à jour",
"name": "Nom" "name": "Nom"
}, },
"search": "Rechercher des automatisations", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "File", "queue": "File",
"loading": "Chargement des automatisations...", "loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.", "noMatches": "Aucune automatisation ne correspond à cette vue.",
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "Diperbarui", "updated": "Diperbarui",
"name": "Nama" "name": "Nama"
}, },
"search": "Cari otomatisasi", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "Antrean", "queue": "Antrean",
"loading": "Memuat otomasi...", "loading": "Memuat otomasi...",
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.", "noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "更新日時", "updated": "更新日時",
"name": "名前" "name": "名前"
}, },
"search": "自動化を検索", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "キュー", "queue": "キュー",
"loading": "自動タスクを読み込み中...", "loading": "自動タスクを読み込み中...",
"noMatches": "この表示に一致する自動タスクはありません。", "noMatches": "この表示に一致する自動タスクはありません。",
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "업데이트", "updated": "업데이트",
"name": "이름" "name": "이름"
}, },
"search": "자동화 검색", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "대기열", "queue": "대기열",
"loading": "자동화를 불러오는 중...", "loading": "자동화를 불러오는 중...",
"noMatches": "이 보기와 일치하는 자동화가 없습니다.", "noMatches": "이 보기와 일치하는 자동화가 없습니다.",
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "Đã cập nhật", "updated": "Đã cập nhật",
"name": "Tên" "name": "Tên"
}, },
"search": "Tìm tự động hóa", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "Hàng đợi", "queue": "Hàng đợi",
"loading": "Đang tải tự động hóa...", "loading": "Đang tải tự động hóa...",
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.", "noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "更新时间", "updated": "更新时间",
"name": "名称" "name": "名称"
}, },
"search": "搜索自动任务", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "任务队列", "queue": "任务队列",
"loading": "正在加载自动任务...", "loading": "正在加载自动任务...",
"noMatches": "当前视图没有匹配的自动任务。", "noMatches": "当前视图没有匹配的自动任务。",
+1 -1
View File
@@ -493,7 +493,7 @@
"updated": "更新時間", "updated": "更新時間",
"name": "名稱" "name": "名稱"
}, },
"search": "搜尋自動任務", "search": "name:quiz chat:WeChat cron:09-23",
"queue": "任務佇列", "queue": "任務佇列",
"loading": "正在載入自動任務...", "loading": "正在載入自動任務...",
"noMatches": "目前檢視沒有符合的自動任務。", "noMatches": "目前檢視沒有符合的自動任務。",
+12 -1
View File
@@ -393,7 +393,7 @@ describe("App layout", () => {
enabled: true, enabled: true,
protected: false, protected: false,
delete_after_run: false, delete_after_run: false,
schedule: { kind: "every", every_ms: 3_600_000 }, schedule: { kind: "cron", expr: "30 9-23 * * *", tz: "Asia/Shanghai" },
payload: { payload: {
message: "Send a quiz", message: "Send a quiz",
kind: "agent_turn", kind: "agent_turn",
@@ -452,6 +452,17 @@ describe("App layout", () => {
"page", "page",
); );
expect(document.title).toBe("Automations · nanobot"); expect(document.title).toBe("Automations · nanobot");
const searchInput = within(automationsMain as HTMLElement).getByPlaceholderText(
"name:quiz chat:WeChat cron:09-23",
);
fireEvent.change(searchInput, { target: { value: "chat:WeChat" } });
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
fireEvent.change(searchInput, { target: { value: "cron: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 () => { it("edits a past one-time automation without resubmitting its old schedule", async () => {