diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 6128f57ac..2a56212d4 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -38,10 +38,26 @@ AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10')) #################### +def _parse_rule(s: str): + """Parse RRULE with clock-aligned DTSTART for sub-daily frequencies. + + MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00) + so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10). + """ + raw = s.replace('RRULE:', '') + parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p) + freq = parts.get('FREQ', '') + + if freq in ('MINUTELY', 'HOURLY'): + epoch = datetime(2000, 1, 1, 0, 0, 0) + return rrulestr(s, dtstart=epoch, ignoretz=True) + return rrulestr(s, ignoretz=True) + + def validate_rrule(s: str) -> None: """Raise ValueError if the RRULE is malformed or exhausted.""" try: - rule = rrulestr(s, ignoretz=True) + rule = _parse_rule(s) except Exception as e: raise ValueError(f'Invalid RRULE: {e}') if rule.after(datetime.now()) is None: @@ -51,7 +67,7 @@ def validate_rrule(s: str) -> None: def next_run_ns(s: str, tz: str = None) -> Optional[int]: """Next occurrence as epoch nanoseconds, respecting user timezone.""" now = datetime.now(ZoneInfo(tz)) if tz else datetime.now() - dt = rrulestr(s, ignoretz=True).after(now.replace(tzinfo=None)) + dt = _parse_rule(s).after(now.replace(tzinfo=None)) if dt is None: return None if tz: @@ -61,7 +77,7 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]: def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]: """Compute next N occurrences for UI preview.""" - rule = rrulestr(s, ignoretz=True) + rule = _parse_rule(s) result = [] dt = datetime.now() for _ in range(n): diff --git a/src/lib/components/AutomationModal.svelte b/src/lib/components/AutomationModal.svelte index a64158c3e..133e67c7e 100644 --- a/src/lib/components/AutomationModal.svelte +++ b/src/lib/components/AutomationModal.svelte @@ -36,6 +36,8 @@ let minute = 0; let selectedDays: string[] = []; let monthDay = 1; + let onceDate = ''; + let onceTime = '09:00'; let loading = false; let showScheduleDropdown = false; @@ -56,6 +58,7 @@ : $models; const FREQUENCIES = [ + { key: 'ONCE', label: 'Once' }, { key: 'HOURLY', label: 'Hourly' }, { key: 'DAILY', label: 'Daily' }, { key: 'WEEKLY', label: 'Weekly' }, @@ -74,6 +77,10 @@ ]; const buildVisualRrule = (): string => { + if (lastVisualFrequency === 'ONCE') { + const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00'; + return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`; + } let parts = [`FREQ=${lastVisualFrequency}`]; if (interval > 1) parts.push(`INTERVAL=${interval}`); if (lastVisualFrequency === 'WEEKLY' && selectedDays.length) { @@ -96,6 +103,12 @@ lastVisualFrequency = frequency; } + $: if (frequency === 'ONCE' && !onceDate) { + const soon = new Date(Date.now() + 5 * 60_000); + onceDate = soon.toISOString().split('T')[0]; + onceTime = `${String(soon.getHours()).padStart(2, '0')}:${String(soon.getMinutes()).padStart(2, '0')}`; + } + $: { if (frequency === 'CUSTOM' && prevFrequency !== 'CUSTOM') { customRrule = buildVisualRrule(); @@ -105,6 +118,10 @@ const buildRrule = (): string => { if (frequency === 'CUSTOM') return customRrule; + if (frequency === 'ONCE') { + const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00'; + return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`; + } let parts = [`FREQ=${frequency}`]; if (interval > 1) parts.push(`INTERVAL=${interval}`); if (frequency === 'WEEKLY' && selectedDays.length) { @@ -121,6 +138,16 @@ }; const parseRrule = (s: string) => { + // Detect ONCE (COUNT=1 with DTSTART) + if (s.includes('COUNT=1')) { + frequency = 'ONCE'; + const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/); + if (match) { + onceDate = `${match[1]}-${match[2]}-${match[3]}`; + onceTime = `${match[4]}:${match[5]}`; + } + return; + } const parts: Record = {}; s.replace('RRULE:', '') .split(';') @@ -143,11 +170,7 @@ }; const scheduleLabel = (freq, intv, h, min, days, mDay) => { - const h12 = h > 12 ? h - 12 : h === 0 ? 12 : h; - const ampm = h >= 12 ? 'PM' : 'AM'; - const m = String(min).padStart(2, '0'); - const time = `${h12}:${m} ${ampm}`; - + if (freq === 'ONCE') return 'Once'; if (freq === 'HOURLY') return 'Hourly'; if (freq === 'DAILY') return 'Daily'; if (freq === 'WEEKLY') return 'Weekly'; @@ -161,6 +184,13 @@ toast.error($i18n.t('Name, prompt, and model are required')); return; } + if (frequency === 'ONCE') { + const scheduled = new Date(`${onceDate}T${onceTime}`); + if (scheduled <= new Date()) { + toast.error($i18n.t('Scheduled time must be in the future')); + return; + } + } loading = true; try { const form: AutomationForm = { @@ -203,6 +233,8 @@ is_active = true; frequency = 'DAILY'; interval = 1; + onceDate = ''; + onceTime = '09:00'; hour = 9; minute = 0; selectedDays = []; @@ -287,24 +319,22 @@
{$i18n.t('Schedule')}
-
- {#each FREQUENCIES as f} - - {/each} +
+
{#if frequency === 'CUSTOM'} @@ -317,31 +347,25 @@ on:click={(e) => e.stopPropagation()} />
- {:else} + {:else if frequency !== 'HOURLY'}
- {#if frequency === 'HOURLY'} + {#if frequency === 'ONCE'}
- {$i18n.t('Every')} e.stopPropagation()} /> - hr - {$i18n.t('at')} - : +
+
e.stopPropagation()} /> - min
{:else}
@@ -354,7 +378,7 @@ hour = h; minute = m; }} - class="bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 px-2 border border-gray-200 dark:border-gray-700 dark:color-scheme-dark" + class="bg-transparent text-center outline-hidden text-xs dark:color-scheme-dark" on:click={(e) => e.stopPropagation()} />
@@ -368,7 +392,7 @@ bind:value={monthDay} min={1} max={31} - class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700" + class="w-8 bg-transparent text-center outline-hidden text-xs" on:click={(e) => e.stopPropagation()} />
diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte index 86b287946..98d5ad82c 100644 --- a/src/routes/(app)/automations/+page.svelte +++ b/src/routes/(app)/automations/+page.svelte @@ -100,6 +100,15 @@ }; const formatRRule = (rrule: string): string => { + // Detect one-time schedule (ONCE) + if (rrule.includes('COUNT=1')) { + const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/); + if (match) { + const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`); + return `Once ยท ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`; + } + return 'Once'; + } const parts: Record = {}; rrule .replace('RRULE:', '')