diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index b95b8d66..4db5e2b8 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -3438,10 +3438,10 @@ function AutomationsSettings({ const locale = i18n.resolvedLanguage || i18n.language; const [selectedJobId, setSelectedJobId] = useState(null); const filtered = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); + const searchTokens = parseAutomationSearchQuery(query); return sortAutomationJobs(jobs, sort) .filter((job) => automationMatchesFilter(job, filter)) - .filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery)); + .filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens)); }, [filter, jobs, query, sort]); const activeCount = jobs.filter((job) => { const key = automationStatusKey(job); @@ -3478,16 +3478,16 @@ function AutomationsSettings({ return (
-
+
-
+
{summaryOptions.map((option) => (
@@ -4364,24 +4364,156 @@ function automationScheduleChanged( return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN); } -function automationSearchText(job: SessionAutomationJob): string { - const originText = job.origin - ? job.origin.channel === "websocket" - ? [job.origin.session_key, job.origin.title, job.origin.preview] - : [job.origin.channel] - : []; +type AutomationSearchField = "id" | "name" | "message" | "chat" | "cron" | "schedule" | "status"; + +interface AutomationSearchToken { + field: AutomationSearchField | null; + value: string; +} + +const AUTOMATION_SEARCH_FIELDS = new Set([ + "id", + "name", + "message", + "chat", + "cron", + "schedule", + "status", +]); + +const AUTOMATION_CHANNEL_LABELS: Record = { + 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 { + 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 [ job.id, job.name, job.payload.message, - job.schedule.kind, - job.schedule.expr, - job.schedule.tz, - ...originText, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); + ...scheduleParts, + automationStatusKey(job), + ...originParts, + ]; +} + +function automationOriginSearchParts(job: SessionAutomationJob): Array { + 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 { + const schedule = job.schedule; + const parts: Array = [ + 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 { @@ -4431,25 +4563,8 @@ function automationChannelLabel( tx: (key: string, fallback: string, values?: Record) => string, ): string { const key = channel.trim().toLowerCase(); - const labels: Record = { - 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", - }; - return labels[key] - ? tx(`settings.automations.channels.${key}`, labels[key]) + return AUTOMATION_CHANNEL_LABELS[key] + ? tx(`settings.automations.channels.${key}`, AUTOMATION_CHANNEL_LABELS[key]) : channel; } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 47d2bb25..125f2e9d 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -493,7 +493,7 @@ "updated": "Updated", "name": "Name" }, - "search": "Search automations", + "search": "name:quiz chat:WeChat cron:09-23", "queue": "Queue", "loading": "Loading automations...", "noMatches": "No automations match this view.", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 94c6bf05..ce69a2d4 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -493,7 +493,7 @@ "updated": "Actualizada", "name": "Nombre" }, - "search": "Buscar automatizaciones", + "search": "name:quiz chat:WeChat cron:09-23", "queue": "Cola", "loading": "Cargando automatizaciones...", "noMatches": "No hay automatizaciones que coincidan con esta vista.", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 7519eef3..0f02183e 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -493,7 +493,7 @@ "updated": "Mise à jour", "name": "Nom" }, - "search": "Rechercher des automatisations", + "search": "name:quiz chat:WeChat cron:09-23", "queue": "File", "loading": "Chargement des automatisations...", "noMatches": "Aucune automatisation ne correspond à cette vue.", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 43cc0d8f..358b325f 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -493,7 +493,7 @@ "updated": "Diperbarui", "name": "Nama" }, - "search": "Cari otomatisasi", + "search": "name:quiz chat:WeChat cron:09-23", "queue": "Antrean", "loading": "Memuat otomasi...", "noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index d530c2a6..60b4e00c 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -493,7 +493,7 @@ "updated": "更新日時", "name": "名前" }, - "search": "自動化を検索", + "search": "name:quiz chat:WeChat cron:09-23", "queue": "キュー", "loading": "自動タスクを読み込み中...", "noMatches": "この表示に一致する自動タスクはありません。", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 8e3ac4ee..e2fbf801 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -493,7 +493,7 @@ "updated": "업데이트", "name": "이름" }, - "search": "자동화 검색", + "search": "name:quiz chat:WeChat cron:09-23", "queue": "대기열", "loading": "자동화를 불러오는 중...", "noMatches": "이 보기와 일치하는 자동화가 없습니다.", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 888234d3..e2daf0bb 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -493,7 +493,7 @@ "updated": "Đã cập nhật", "name": "Tên" }, - "search": "Tìm tự động hóa", + "search": "name:quiz chat:WeChat cron:09-23", "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.", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 79828a17..e683c360 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -493,7 +493,7 @@ "updated": "更新时间", "name": "名称" }, - "search": "搜索自动任务", + "search": "name:quiz chat:WeChat cron:09-23", "queue": "任务队列", "loading": "正在加载自动任务...", "noMatches": "当前视图没有匹配的自动任务。", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index a114bf44..925561b4 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -493,7 +493,7 @@ "updated": "更新時間", "name": "名稱" }, - "search": "搜尋自動任務", + "search": "name:quiz chat:WeChat cron:09-23", "queue": "任務佇列", "loading": "正在載入自動任務...", "noMatches": "目前檢視沒有符合的自動任務。", diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 46657c28..229baa1f 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -393,7 +393,7 @@ describe("App layout", () => { enabled: true, protected: false, delete_after_run: false, - schedule: { kind: "every", every_ms: 3_600_000 }, + schedule: { kind: "cron", expr: "30 9-23 * * *", tz: "Asia/Shanghai" }, payload: { message: "Send a quiz", kind: "agent_turn", @@ -452,6 +452,17 @@ describe("App layout", () => { "page", ); 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 () => {