diff --git a/apps/web/package.json b/apps/web/package.json
index 94ef1575..2e192a58 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -23,6 +23,8 @@
"@xterm/addon-serialize": "^0.14.0",
"@xterm/xterm": "^6.0.0",
"animate.css": "^4.1.1",
+ "cron-parser": "^5.5.0",
+ "cronstrue": "^3.14.0",
"dotenv": "^17.2.3",
"echarts": "^6.0.0",
"katex": "^0.16.28",
diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json
index a332e783..3e8f07cf 100644
--- a/apps/web/src/i18n/locales/en.json
+++ b/apps/web/src/i18n/locales/en.json
@@ -4,6 +4,7 @@
"cancel": "Cancel",
"back": "Back",
"save": "Save",
+ "create": "Create",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
@@ -972,6 +973,8 @@
"compactionModel": "Compaction Model",
"compactionModelDescription": "Select a model for summarization. Defaults to the bot's chat model if not set.",
"compactionModelPlaceholder": "Use chat model (default)",
+ "showToolCallsInIM": "Show Tool Calls in IM",
+ "showToolCallsInIMDescription": "Surface tool execution status (running / completed / failed) as a single message in IM channels. Off by default.",
"browserContext": "Browser Context",
"browserContextPlaceholder": "Select browser context (disabled if empty)",
"allowGuest": "Default ACL Effect",
@@ -1240,7 +1243,86 @@
"updatedAt": "Updated",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
- "unlimited": "∞"
+ "unlimited": "∞",
+ "actions": "Actions",
+ "create": "New Schedule",
+ "edit": "Edit",
+ "delete": "Delete",
+ "deleteConfirm": "Delete schedule \"{name}\"? This cannot be undone.",
+ "deleteSuccess": "Schedule deleted",
+ "deleteFailed": "Failed to delete schedule",
+ "saveSuccess": "Schedule saved",
+ "saveFailed": "Failed to save schedule",
+ "form": {
+ "name": "Name",
+ "namePlaceholder": "e.g. Morning brief",
+ "description": "Description",
+ "descriptionPlaceholder": "What this schedule does",
+ "command": "Instruction",
+ "commandPlaceholder": "Tell the bot what to do when this fires",
+ "commandHint": "This will be delivered to the bot as a message each time the schedule triggers.",
+ "pattern": "Schedule",
+ "mode": "Mode",
+ "everyMinutes": "Every N minutes (1-59)",
+ "atMinute": "At minute of each hour (0-59)",
+ "hour": "Hour",
+ "hours": "Hours",
+ "hoursHint": "Click to select one or more hours of the day.",
+ "minute": "Minute",
+ "weekdays": "Days of week",
+ "monthDays": "Days of month",
+ "month": "Month",
+ "monthDay": "Day",
+ "maxCalls": "Run limit",
+ "maxCallsUnlimited": "Unlimited",
+ "enabled": "Enabled",
+ "patternPreview": "Cron pattern",
+ "nextRuns": "Next runs ({tz})",
+ "invalidPattern": "Pattern is invalid",
+ "advancedPattern": "Cron expression",
+ "advancedHint": "Standard cron: minute hour day-of-month month day-of-week. Descriptors like @daily are also accepted."
+ },
+ "mode": {
+ "minutes": "Every N minutes",
+ "hourly": "Hourly",
+ "daily": "Daily",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+ "yearly": "Yearly",
+ "advanced": "Advanced (cron)"
+ },
+ "modeHint": {
+ "minutes": "Trigger every N minutes.",
+ "hourly": "Trigger once every hour, at a given minute.",
+ "daily": "Trigger every day at the selected hours.",
+ "weekly": "Trigger on the selected days of the week.",
+ "monthly": "Trigger on the selected days of the month.",
+ "yearly": "Trigger once a year on a specific month and day.",
+ "advanced": "Write a raw cron expression for full control."
+ },
+ "weekday": {
+ "sun": "Sun",
+ "mon": "Mon",
+ "tue": "Tue",
+ "wed": "Wed",
+ "thu": "Thu",
+ "fri": "Fri",
+ "sat": "Sat"
+ },
+ "month": {
+ "jan": "January",
+ "feb": "February",
+ "mar": "March",
+ "apr": "April",
+ "may": "May",
+ "jun": "June",
+ "jul": "July",
+ "aug": "August",
+ "sep": "September",
+ "oct": "October",
+ "nov": "November",
+ "dec": "December"
+ }
},
"history": {
"title": "History",
diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json
index c2b657bc..fddeba03 100644
--- a/apps/web/src/i18n/locales/zh.json
+++ b/apps/web/src/i18n/locales/zh.json
@@ -4,6 +4,7 @@
"cancel": "取消",
"back": "返回",
"save": "保存",
+ "create": "创建",
"add": "添加",
"edit": "编辑",
"delete": "删除",
@@ -968,6 +969,8 @@
"compactionModel": "压缩模型",
"compactionModelDescription": "选择用于摘要的模型,未设置时默认使用聊天模型。",
"compactionModelPlaceholder": "使用聊天模型(默认)",
+ "showToolCallsInIM": "在 IM 中显示工具调用",
+ "showToolCallsInIMDescription": "在 IM 频道中以单条消息持续展示工具执行的状态(进行中 / 已完成 / 失败)。默认关闭。",
"browserContext": "浏览器上下文",
"browserContextPlaceholder": "选择浏览器上下文(未配置时不启用)",
"allowGuest": "ACL 默认行为",
@@ -1236,7 +1239,86 @@
"updatedAt": "更新时间",
"statusEnabled": "已启用",
"statusDisabled": "已禁用",
- "unlimited": "无限制"
+ "unlimited": "无限制",
+ "actions": "操作",
+ "create": "新建任务",
+ "edit": "编辑",
+ "delete": "删除",
+ "deleteConfirm": "确定删除任务 \"{name}\" 吗?此操作无法撤销。",
+ "deleteSuccess": "已删除",
+ "deleteFailed": "删除失败",
+ "saveSuccess": "已保存",
+ "saveFailed": "保存失败",
+ "form": {
+ "name": "名称",
+ "namePlaceholder": "例如:每日早报",
+ "description": "描述",
+ "descriptionPlaceholder": "这个任务做什么",
+ "command": "指令",
+ "commandPlaceholder": "每次触发时让 Bot 做什么",
+ "commandHint": "任务触发时,这段内容会作为消息发送给 Bot。",
+ "pattern": "调度规则",
+ "mode": "模式",
+ "everyMinutes": "每 N 分钟 (1-59)",
+ "atMinute": "每小时的第 M 分钟 (0-59)",
+ "hour": "小时",
+ "hours": "小时",
+ "hoursHint": "点击选择一个或多个小时。",
+ "minute": "分钟",
+ "weekdays": "星期",
+ "monthDays": "每月日期",
+ "month": "月份",
+ "monthDay": "日期",
+ "maxCalls": "运行次数限制",
+ "maxCallsUnlimited": "不限制",
+ "enabled": "启用",
+ "patternPreview": "Cron 表达式",
+ "nextRuns": "接下来的触发时间({tz})",
+ "invalidPattern": "表达式无效",
+ "advancedPattern": "Cron 表达式",
+ "advancedHint": "标准 cron 格式:分 时 日 月 星期。也支持 @daily 等描述符。"
+ },
+ "mode": {
+ "minutes": "每 N 分钟",
+ "hourly": "每小时",
+ "daily": "每天",
+ "weekly": "每周",
+ "monthly": "每月",
+ "yearly": "每年",
+ "advanced": "高级(Cron)"
+ },
+ "modeHint": {
+ "minutes": "每 N 分钟触发一次。",
+ "hourly": "每小时在指定分钟触发。",
+ "daily": "每天在选定的小时触发。",
+ "weekly": "在选定的星期几触发。",
+ "monthly": "在每月选定的日期触发。",
+ "yearly": "每年在指定月份和日期触发。",
+ "advanced": "直接编写 cron 表达式以获得完全控制。"
+ },
+ "weekday": {
+ "sun": "日",
+ "mon": "一",
+ "tue": "二",
+ "wed": "三",
+ "thu": "四",
+ "fri": "五",
+ "sat": "六"
+ },
+ "month": {
+ "jan": "一月",
+ "feb": "二月",
+ "mar": "三月",
+ "apr": "四月",
+ "may": "五月",
+ "jun": "六月",
+ "jul": "七月",
+ "aug": "八月",
+ "sep": "九月",
+ "oct": "十月",
+ "nov": "十一月",
+ "dec": "十二月"
+ }
},
"history": {
"title": "对话历史",
diff --git a/apps/web/src/pages/bots/components/bot-schedule.vue b/apps/web/src/pages/bots/components/bot-schedule.vue
index 0b29a926..95585c14 100644
--- a/apps/web/src/pages/bots/components/bot-schedule.vue
+++ b/apps/web/src/pages/bots/components/bot-schedule.vue
@@ -27,6 +27,13 @@
/>
{{ $t('common.refresh') }}
+
@@ -49,9 +56,17 @@
class="size-6 text-muted-foreground"
/>
-
+
{{ $t('bots.schedule.empty') }}
+
@@ -72,12 +87,12 @@
{{ $t('bots.schedule.calls') }}
|
-
- {{ $t('bots.schedule.createdAt') }}
- |
{{ $t('bots.schedule.updatedAt') }}
|
+
+ {{ $t('bots.schedule.actions') }}
+ |
@@ -86,31 +101,71 @@
:key="item.id"
class="border-b last:border-0 hover:bg-muted/30"
>
-
+ |
{{ item.name }}
{{ item.description }}
|
-
-
+
+
{{ item.pattern }}
+
+ {{ describeItem(item.pattern) }}
+
|
-
-
- {{ item.enabled ? $t('bots.schedule.statusEnabled') : $t('bots.schedule.statusDisabled') }}
-
+ |
+
+ handleToggleEnabled(item, !!val)"
+ />
+
+ {{ item.enabled ? $t('bots.schedule.statusEnabled') : $t('bots.schedule.statusDisabled') }}
+
+
|
-
- {{ item.current_calls ?? 0 }} / {{ item.max_calls || $t('bots.schedule.unlimited') }}
+ |
+ {{ item.current_calls ?? 0 }} / {{ formatMaxCalls(item) }}
|
-
- {{ formatDateTime(item.created_at) }}
- |
-
+ |
{{ formatDateTime(item.updated_at) }}
|
+
+
+ |
|
@@ -154,35 +209,58 @@
+
+
diff --git a/apps/web/src/pages/bots/components/schedule-form-dialog.vue b/apps/web/src/pages/bots/components/schedule-form-dialog.vue
new file mode 100644
index 00000000..b87df2bc
--- /dev/null
+++ b/apps/web/src/pages/bots/components/schedule-form-dialog.vue
@@ -0,0 +1,300 @@
+
+
+
+
+
+
+
+
+
+
+
+ form.enabled = !!v"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.schedule.form.commandHint') }}
+
+
+
+
+
+ patternState = next"
+ />
+
+
+
+
+
+
+ handleMaxCallsUnlimited(!!v)"
+ />
+
+ {{ $t('bots.schedule.form.maxCallsUnlimited') }}
+
+
+
+
form.maxCalls = Math.max(1, Math.floor(Number(v) || 1))"
+ />
+
+
+
+ {{ submitError }}
+
+
+
+
+
+
+
diff --git a/apps/web/src/pages/bots/components/schedule-pattern-builder.vue b/apps/web/src/pages/bots/components/schedule-pattern-builder.vue
new file mode 100644
index 00000000..9fcc5081
--- /dev/null
+++ b/apps/web/src/pages/bots/components/schedule-pattern-builder.vue
@@ -0,0 +1,478 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ modeHint }}
+
+
+
+
+
+
+ update({ intervalMinutes: clampInt(v, 1, 59, 1) })"
+ />
+
+
+
+
+
+ update({ minute: clampInt(v, 0, 59, 0) })"
+ />
+
+
+
+
+
+
+
+ {{ $t('bots.schedule.form.hoursHint') }}
+
+
+
+
+
+
+
+ update({ minute: clampInt(v, 0, 59, 0) })"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ update({ monthDay: clampInt(v, 1, 31, 1) })"
+ />
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.schedule.form.advancedHint') }}
+
+
update({ advancedPattern: String(v) })"
+ />
+
+
+
+
+
+
+
+ {{ $t('bots.schedule.form.patternPreview') }}
+
+
{{ previewPattern }}
+
—
+
+
+
+ {{ humanText }}
+ {{ $t('bots.schedule.form.invalidPattern') }}
+
+
+
+ {{ $t('bots.schedule.form.nextRuns', { tz: effectiveTimezone }) }}
+
+
+ · {{ formatPreviewDate(d) }}
+
+
+
+
+
+
+
diff --git a/apps/web/src/utils/cron-pattern.test.ts b/apps/web/src/utils/cron-pattern.test.ts
new file mode 100644
index 00000000..ea1b499e
--- /dev/null
+++ b/apps/web/src/utils/cron-pattern.test.ts
@@ -0,0 +1,218 @@
+import { describe, expect, it } from 'vitest'
+import {
+ defaultScheduleFormState,
+ describeCron,
+ fromCron,
+ isValidCron,
+ nextRuns,
+ toCron,
+ type ScheduleFormState,
+} from './cron-pattern'
+
+function mk(overrides: Partial): ScheduleFormState {
+ return { ...defaultScheduleFormState(), ...overrides }
+}
+
+describe('toCron', () => {
+ it('minutes mode', () => {
+ expect(toCron(mk({ mode: 'minutes', intervalMinutes: 15 }))).toBe('*/15 * * * *')
+ })
+
+ it('hourly mode', () => {
+ expect(toCron(mk({ mode: 'hourly', minute: 30 }))).toBe('30 * * * *')
+ })
+
+ it('daily mode single hour', () => {
+ expect(toCron(mk({ mode: 'daily', minute: 0, hours: [9] }))).toBe('0 9 * * *')
+ })
+
+ it('daily mode multi hour gets sorted & deduped', () => {
+ expect(toCron(mk({ mode: 'daily', minute: 30, hours: [18, 9, 9, 13] })))
+ .toBe('30 9,13,18 * * *')
+ })
+
+ it('weekly mode', () => {
+ expect(toCron(mk({
+ mode: 'weekly',
+ minute: 0,
+ hours: [9],
+ weekdays: [1, 3, 5],
+ }))).toBe('0 9 * * 1,3,5')
+ })
+
+ it('monthly mode', () => {
+ expect(toCron(mk({
+ mode: 'monthly',
+ minute: 0,
+ hours: [9],
+ monthDays: [1, 15],
+ }))).toBe('0 9 1,15 * *')
+ })
+
+ it('yearly mode', () => {
+ expect(toCron(mk({
+ mode: 'yearly',
+ minute: 0,
+ hours: [12],
+ month: 12,
+ monthDay: 25,
+ }))).toBe('0 12 25 12 *')
+ })
+
+ it('advanced mode passes through trimmed', () => {
+ expect(toCron(mk({ mode: 'advanced', advancedPattern: ' @daily ' }))).toBe('@daily')
+ })
+
+ it('rejects out-of-range values', () => {
+ expect(() => toCron(mk({ mode: 'minutes', intervalMinutes: 60 }))).toThrow()
+ expect(() => toCron(mk({ mode: 'hourly', minute: 60 }))).toThrow()
+ expect(() => toCron(mk({ mode: 'daily', minute: 0, hours: [24] }))).toThrow()
+ })
+})
+
+describe('fromCron', () => {
+ it('recognises minutes mode', () => {
+ const s = fromCron('*/15 * * * *')
+ expect(s.mode).toBe('minutes')
+ expect(s.intervalMinutes).toBe(15)
+ })
+
+ it('recognises hourly mode', () => {
+ const s = fromCron('30 * * * *')
+ expect(s.mode).toBe('hourly')
+ expect(s.minute).toBe(30)
+ })
+
+ it('recognises daily mode with multiple hours', () => {
+ const s = fromCron('30 9,13,18 * * *')
+ expect(s.mode).toBe('daily')
+ expect(s.minute).toBe(30)
+ expect(s.hours).toEqual([9, 13, 18])
+ })
+
+ it('recognises weekly mode', () => {
+ const s = fromCron('0 9 * * 1,3,5')
+ expect(s.mode).toBe('weekly')
+ expect(s.minute).toBe(0)
+ expect(s.hours).toEqual([9])
+ expect(s.weekdays).toEqual([1, 3, 5])
+ })
+
+ it('recognises monthly mode', () => {
+ const s = fromCron('0 9 1,15 * *')
+ expect(s.mode).toBe('monthly')
+ expect(s.monthDays).toEqual([1, 15])
+ expect(s.hours).toEqual([9])
+ })
+
+ it('recognises yearly mode', () => {
+ const s = fromCron('0 12 25 12 *')
+ expect(s.mode).toBe('yearly')
+ expect(s.month).toBe(12)
+ expect(s.monthDay).toBe(25)
+ expect(s.hours).toEqual([12])
+ expect(s.minute).toBe(0)
+ })
+
+ it('falls back to advanced for descriptors', () => {
+ const s = fromCron('@daily')
+ expect(s.mode).toBe('advanced')
+ expect(s.advancedPattern).toBe('@daily')
+ })
+
+ it('falls back to advanced for 6-field cron', () => {
+ const s = fromCron('0 */5 * * * *')
+ expect(s.mode).toBe('advanced')
+ expect(s.advancedPattern).toBe('0 */5 * * * *')
+ })
+
+ it('falls back to advanced for range expressions', () => {
+ const s = fromCron('30 9 1-15 * *')
+ expect(s.mode).toBe('advanced')
+ expect(s.advancedPattern).toBe('30 9 1-15 * *')
+ })
+
+ it('falls back to advanced for step in hour field', () => {
+ const s = fromCron('0 */2 * * *')
+ expect(s.mode).toBe('advanced')
+ })
+
+ it('falls back to advanced for named weekdays', () => {
+ const s = fromCron('0 9 * * MON-FRI')
+ expect(s.mode).toBe('advanced')
+ })
+
+ it('falls back to advanced for empty input', () => {
+ const s = fromCron(' ')
+ expect(s.mode).toBe('advanced')
+ expect(s.advancedPattern).toBe('')
+ })
+})
+
+describe('round-trip fromCron(toCron(state))', () => {
+ const cases: Array<{ label: string, state: ScheduleFormState }> = [
+ { label: 'minutes', state: mk({ mode: 'minutes', intervalMinutes: 5 }) },
+ { label: 'hourly', state: mk({ mode: 'hourly', minute: 45 }) },
+ { label: 'daily single', state: mk({ mode: 'daily', minute: 0, hours: [9] }) },
+ { label: 'daily multi', state: mk({ mode: 'daily', minute: 30, hours: [9, 13, 18] }) },
+ { label: 'weekly', state: mk({ mode: 'weekly', minute: 0, hours: [9], weekdays: [1, 3, 5] }) },
+ { label: 'monthly', state: mk({ mode: 'monthly', minute: 0, hours: [9], monthDays: [1, 15] }) },
+ { label: 'yearly', state: mk({ mode: 'yearly', minute: 0, hours: [12], month: 12, monthDay: 25 }) },
+ ]
+
+ for (const { label, state } of cases) {
+ it(label, () => {
+ const pattern = toCron(state)
+ const parsed = fromCron(pattern)
+ expect(parsed.mode).toBe(state.mode)
+ // Re-emit and compare canonical strings to confirm no drift.
+ expect(toCron(parsed)).toBe(pattern)
+ })
+ }
+})
+
+describe('describeCron', () => {
+ it('returns an english description', () => {
+ const out = describeCron('0 9 * * *', 'en')
+ expect(out).toBeTruthy()
+ expect(out!.toLowerCase()).toContain('9')
+ })
+
+ it('returns a chinese description', () => {
+ const out = describeCron('0 9 * * *', 'zh')
+ expect(out).toBeTruthy()
+ })
+
+ it('returns undefined for invalid', () => {
+ expect(describeCron('not a cron', 'en')).toBeUndefined()
+ })
+})
+
+describe('nextRuns', () => {
+ it('returns requested number of dates for valid pattern', () => {
+ const runs = nextRuns('0 9 * * *', 'UTC', 3)
+ expect(runs).toHaveLength(3)
+ for (const d of runs) {
+ expect(d.getUTCHours()).toBe(9)
+ }
+ })
+
+ it('returns empty for invalid pattern', () => {
+ expect(nextRuns('not valid', 'UTC', 3)).toEqual([])
+ })
+})
+
+describe('isValidCron', () => {
+ it('accepts 5-field cron', () => {
+ expect(isValidCron('0 9 * * *')).toBe(true)
+ })
+
+ it('accepts descriptors', () => {
+ expect(isValidCron('@daily')).toBe(true)
+ })
+
+ it('rejects garbage', () => {
+ expect(isValidCron('')).toBe(false)
+ expect(isValidCron('hello world')).toBe(false)
+ })
+})
diff --git a/apps/web/src/utils/cron-pattern.ts b/apps/web/src/utils/cron-pattern.ts
new file mode 100644
index 00000000..b3e0e9fb
--- /dev/null
+++ b/apps/web/src/utils/cron-pattern.ts
@@ -0,0 +1,311 @@
+import cronstrue from 'cronstrue'
+import 'cronstrue/locales/zh_CN'
+import { CronExpressionParser } from 'cron-parser'
+
+export type ScheduleMode =
+ | 'minutes'
+ | 'hourly'
+ | 'daily'
+ | 'weekly'
+ | 'monthly'
+ | 'yearly'
+ | 'advanced'
+
+export interface ScheduleFormState {
+ mode: ScheduleMode
+ intervalMinutes: number
+ minute: number
+ hours: number[]
+ weekdays: number[]
+ monthDays: number[]
+ month: number
+ monthDay: number
+ advancedPattern: string
+}
+
+// Default form state used when creating a new schedule. Chosen so that a user
+// who simply clicks "create" and hits save gets a reasonable daily-at-09:00
+// pattern.
+export function defaultScheduleFormState(): ScheduleFormState {
+ return {
+ mode: 'daily',
+ intervalMinutes: 30,
+ minute: 0,
+ hours: [9],
+ weekdays: [1, 2, 3, 4, 5],
+ monthDays: [1],
+ month: 1,
+ monthDay: 1,
+ advancedPattern: '',
+ }
+}
+
+function assertInt(value: number, min: number, max: number, label: string) {
+ if (!Number.isInteger(value) || value < min || value > max) {
+ throw new Error(`${label} must be an integer in [${min}, ${max}], got ${value}`)
+ }
+}
+
+function dedupSort(values: number[]): number[] {
+ return Array.from(new Set(values)).sort((a, b) => a - b)
+}
+
+function formatList(values: number[]): string {
+ const normalized = dedupSort(values)
+ if (normalized.length === 0) throw new Error('list cannot be empty')
+ return normalized.join(',')
+}
+
+// Produce a canonical 5-field cron pattern from the form state. Always returns
+// the standard `minute hour dom month dow` shape (never seconds, never
+// descriptors) so that fromCron can always recognise outputs of toCron and
+// round-trip to the same state.
+export function toCron(state: ScheduleFormState): string {
+ switch (state.mode) {
+ case 'minutes': {
+ assertInt(state.intervalMinutes, 1, 59, 'intervalMinutes')
+ return `*/${state.intervalMinutes} * * * *`
+ }
+ case 'hourly': {
+ assertInt(state.minute, 0, 59, 'minute')
+ return `${state.minute} * * * *`
+ }
+ case 'daily': {
+ assertInt(state.minute, 0, 59, 'minute')
+ state.hours.forEach(h => assertInt(h, 0, 23, 'hour'))
+ const hourField = formatList(state.hours)
+ return `${state.minute} ${hourField} * * *`
+ }
+ case 'weekly': {
+ assertInt(state.minute, 0, 59, 'minute')
+ if (state.hours.length !== 1) throw new Error('weekly mode requires a single hour')
+ assertInt(state.hours[0]!, 0, 23, 'hour')
+ state.weekdays.forEach(d => assertInt(d, 0, 6, 'weekday'))
+ const dowField = formatList(state.weekdays)
+ return `${state.minute} ${state.hours[0]} * * ${dowField}`
+ }
+ case 'monthly': {
+ assertInt(state.minute, 0, 59, 'minute')
+ if (state.hours.length !== 1) throw new Error('monthly mode requires a single hour')
+ assertInt(state.hours[0]!, 0, 23, 'hour')
+ state.monthDays.forEach(d => assertInt(d, 1, 31, 'monthDay'))
+ const domField = formatList(state.monthDays)
+ return `${state.minute} ${state.hours[0]} ${domField} * *`
+ }
+ case 'yearly': {
+ assertInt(state.minute, 0, 59, 'minute')
+ if (state.hours.length !== 1) throw new Error('yearly mode requires a single hour')
+ assertInt(state.hours[0]!, 0, 23, 'hour')
+ assertInt(state.month, 1, 12, 'month')
+ assertInt(state.monthDay, 1, 31, 'monthDay')
+ return `${state.minute} ${state.hours[0]} ${state.monthDay} ${state.month} *`
+ }
+ case 'advanced':
+ return state.advancedPattern.trim()
+ }
+}
+
+// --- fromCron helpers ---------------------------------------------------------
+
+// Strictly match "*", returning true. No range/step tolerance — we want
+// lossless round-trips only.
+function isStar(field: string): boolean {
+ return field === '*'
+}
+
+// Match a single non-negative integer. Returns undefined if not a plain int.
+function parseIntField(field: string): number | undefined {
+ if (!/^\d+$/.test(field)) return undefined
+ return Number(field)
+}
+
+// Match a plain integer list "a,b,c" (no ranges, no steps). Returns sorted
+// unique numbers, or undefined on any non-conforming input.
+function parseIntList(field: string): number[] | undefined {
+ if (field === '') return undefined
+ const parts = field.split(',')
+ const out: number[] = []
+ for (const part of parts) {
+ const n = parseIntField(part)
+ if (n === undefined) return undefined
+ out.push(n)
+ }
+ return dedupSort(out)
+}
+
+function parseStep(field: string): number | undefined {
+ const m = /^\*\/(\d+)$/.exec(field)
+ if (!m) return undefined
+ const n = Number(m[1])
+ return Number.isInteger(n) ? n : undefined
+}
+
+function inRange(values: number[], min: number, max: number): boolean {
+ return values.every(v => v >= min && v <= max)
+}
+
+// Parse a stored pattern back into form state. Any pattern that toCron could
+// not have produced (descriptors, 6-field seconds cron, ranges, steps other
+// than the minutes mode, named day-of-week tokens, etc.) falls back to
+// 'advanced' with the raw text preserved. This is intentional — lossy
+// recognition would let the builder UI silently rewrite the AI-generated
+// pattern on edit.
+export function fromCron(pattern: string): ScheduleFormState {
+ const base = defaultScheduleFormState()
+ const raw = pattern.trim()
+ const advanced: ScheduleFormState = { ...base, mode: 'advanced', advancedPattern: raw }
+ if (!raw) return advanced
+
+ // Descriptors (@daily, @every 1h, ...) and seconds cron have 1 or 6
+ // space-separated tokens respectively; only 5-field standard cron maps to
+ // structured modes.
+ const fields = raw.split(/\s+/)
+ if (fields.length !== 5) return advanced
+
+ const [minuteF, hourF, domF, monthF, dowF] = fields as [string, string, string, string, string]
+
+ // minutes: */N * * * *
+ {
+ const step = parseStep(minuteF)
+ if (step !== undefined && isStar(hourF) && isStar(domF) && isStar(monthF) && isStar(dowF)) {
+ if (step >= 1 && step <= 59) {
+ return { ...base, mode: 'minutes', intervalMinutes: step }
+ }
+ }
+ }
+
+ // hourly: M * * * *
+ {
+ const m = parseIntField(minuteF)
+ if (m !== undefined && isStar(hourF) && isStar(domF) && isStar(monthF) && isStar(dowF)) {
+ if (m >= 0 && m <= 59) {
+ return { ...base, mode: 'hourly', minute: m }
+ }
+ }
+ }
+
+ // daily: M H[,H] * * *
+ {
+ const m = parseIntField(minuteF)
+ const hours = parseIntList(hourF)
+ if (
+ m !== undefined && m >= 0 && m <= 59
+ && hours && hours.length > 0 && inRange(hours, 0, 23)
+ && isStar(domF) && isStar(monthF) && isStar(dowF)
+ ) {
+ return { ...base, mode: 'daily', minute: m, hours }
+ }
+ }
+
+ // weekly: M H * * DOW[,DOW]
+ {
+ const m = parseIntField(minuteF)
+ const h = parseIntField(hourF)
+ const weekdays = parseIntList(dowF)
+ if (
+ m !== undefined && m >= 0 && m <= 59
+ && h !== undefined && h >= 0 && h <= 23
+ && isStar(domF) && isStar(monthF)
+ && weekdays && weekdays.length > 0 && inRange(weekdays, 0, 6)
+ ) {
+ return { ...base, mode: 'weekly', minute: m, hours: [h], weekdays }
+ }
+ }
+
+ // monthly: M H D[,D] * *
+ {
+ const m = parseIntField(minuteF)
+ const h = parseIntField(hourF)
+ const monthDays = parseIntList(domF)
+ if (
+ m !== undefined && m >= 0 && m <= 59
+ && h !== undefined && h >= 0 && h <= 23
+ && monthDays && monthDays.length > 0 && inRange(monthDays, 1, 31)
+ && isStar(monthF) && isStar(dowF)
+ ) {
+ return { ...base, mode: 'monthly', minute: m, hours: [h], monthDays }
+ }
+ }
+
+ // yearly: M H D Mo *
+ {
+ const m = parseIntField(minuteF)
+ const h = parseIntField(hourF)
+ const d = parseIntField(domF)
+ const mo = parseIntField(monthF)
+ if (
+ m !== undefined && m >= 0 && m <= 59
+ && h !== undefined && h >= 0 && h <= 23
+ && d !== undefined && d >= 1 && d <= 31
+ && mo !== undefined && mo >= 1 && mo <= 12
+ && isStar(dowF)
+ ) {
+ return { ...base, mode: 'yearly', minute: m, hours: [h], month: mo, monthDay: d }
+ }
+ }
+
+ return advanced
+}
+
+// --- preview helpers ---------------------------------------------------------
+
+export type CronLocale = 'en' | 'zh'
+
+// Returns a localized human-readable description of the cron pattern, or
+// undefined if cronstrue cannot parse it.
+export function describeCron(pattern: string, locale: CronLocale): string | undefined {
+ const trimmed = pattern.trim()
+ if (!trimmed) return undefined
+ try {
+ return cronstrue.toString(trimmed, {
+ locale: locale === 'zh' ? 'zh_CN' : 'en',
+ use24HourTimeFormat: true,
+ throwExceptionOnParseError: true,
+ verbose: false,
+ })
+ } catch {
+ return undefined
+ }
+}
+
+// Returns the next `count` trigger dates for the given pattern, evaluated in
+// the provided IANA timezone. Returns an empty array on parse failure.
+export function nextRuns(pattern: string, timezone: string | undefined, count: number): Date[] {
+ const trimmed = pattern.trim()
+ if (!trimmed) return []
+ try {
+ const tz = timezone && timezone.trim() !== '' ? timezone : undefined
+ const iter = CronExpressionParser.parse(trimmed, tz ? { tz } : {})
+ const out: Date[] = []
+ for (let i = 0; i < count; i++) {
+ const d = iter.next().toDate()
+ out.push(d)
+ }
+ return out
+ } catch {
+ return []
+ }
+}
+
+// Returns true iff `pattern` can be parsed by cron-parser. Used to guard
+// submission of the 'advanced' mode.
+export function isValidCron(pattern: string): boolean {
+ const trimmed = pattern.trim()
+ if (!trimmed) return false
+ try {
+ CronExpressionParser.parse(trimmed)
+ return true
+ } catch {
+ return false
+ }
+}
+
+// Localized weekday/month labels. 0 = Sunday per ISO cron convention.
+export const WEEKDAY_KEYS = [
+ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat',
+] as const
+
+export const MONTH_KEYS = [
+ 'jan', 'feb', 'mar', 'apr', 'may', 'jun',
+ 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
+] as const
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d6e86d9e..f8524b05 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -134,6 +134,12 @@ importers:
animate.css:
specifier: ^4.1.1
version: 4.1.1
+ cron-parser:
+ specifier: ^5.5.0
+ version: 5.5.0
+ cronstrue:
+ specifier: ^3.14.0
+ version: 3.14.0
dotenv:
specifier: ^17.2.3
version: 17.2.3
@@ -2788,6 +2794,14 @@ packages:
cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
+ cron-parser@5.5.0:
+ resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==}
+ engines: {node: '>=18'}
+
+ cronstrue@3.14.0:
+ resolution: {integrity: sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==}
+ hasBin: true
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3703,6 +3717,10 @@ packages:
peerDependencies:
vue: '>=3.0.1'
+ luxon@3.7.2:
+ resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
+ engines: {node: '>=12'}
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -7183,6 +7201,12 @@ snapshots:
dependencies:
layout-base: 2.0.1
+ cron-parser@5.5.0:
+ dependencies:
+ luxon: 3.7.2
+
+ cronstrue@3.14.0: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -8197,6 +8221,8 @@ snapshots:
dependencies:
vue: 3.5.26(typescript@5.9.3)
+ luxon@3.7.2: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
diff --git a/vitest.config.ts b/vitest.config.ts
index f02c28ef..40b47d8e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -8,6 +8,7 @@ export default defineConfig({
globals: true,
include: [
'packages/**/*.test.ts',
+ 'apps/**/*.test.ts',
],
env: process.env,
testTimeout: Infinity,