feat: automation

This commit is contained in:
Timothy Jaeryang Baek
2026-03-31 23:36:01 -05:00
parent 98570d3547
commit e6f38f52c8
14 changed files with 2314 additions and 12 deletions
+278
View File
@@ -0,0 +1,278 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type AutomationData = {
prompt: string;
model_id: string;
rrule: string;
};
export type AutomationForm = {
name: string;
data: AutomationData;
meta?: {
system_prompt?: string;
temperature?: number;
max_tokens?: number;
webhook?: string;
};
is_active?: boolean;
};
export type AutomationRunModel = {
id: string;
automation_id: string;
chat_id: string | null;
status: string;
error: string | null;
created_at: number;
};
export type AutomationResponse = {
id: string;
user_id: string;
name: string;
data: AutomationData;
meta: Record<string, any> | null;
is_active: boolean;
last_run_at: number | null;
next_run_at: number | null;
created_at: number;
updated_at: number;
last_run: AutomationRunModel | null;
next_runs: number[] | null;
};
export const getAutomations = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createAutomation = async (token: string, form: AutomationForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAutomationById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateAutomationById = async (token: string, id: string, form: AutomationForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const toggleAutomationById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/toggle`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const runAutomationById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/run`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteAutomationById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAutomationRuns = async (
token: string,
id: string,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/automations/${id}/runs?skip=${skip}&limit=${limit}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
+518
View File
@@ -0,0 +1,518 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { models } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Check from '$lib/components/icons/Check.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
createAutomation,
updateAutomationById,
type AutomationForm,
type AutomationResponse
} from '$lib/apis/automations';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let show = false;
export let automation: AutomationResponse | null = null;
let name = '';
let prompt = '';
let model_id = '';
let is_active = true;
let frequency = 'DAILY';
let interval = 1;
let hour = 9;
let minute = 0;
let selectedDays: string[] = [];
let monthDay = 1;
let loading = false;
let showScheduleDropdown = false;
let showModelDropdown = false;
let modelSearch = '';
let customRrule = '';
$: modelLabel = model_id
? $models.find((m) => m.id === model_id)?.name || model_id
: $i18n.t('Select model');
$: filteredModels = modelSearch
? $models.filter(
(m) =>
m.name.toLowerCase().includes(modelSearch.toLowerCase()) ||
m.id.toLowerCase().includes(modelSearch.toLowerCase())
)
: $models;
const FREQUENCIES = [
{ key: 'HOURLY', label: 'Hourly' },
{ key: 'DAILY', label: 'Daily' },
{ key: 'WEEKLY', label: 'Weekly' },
{ key: 'MONTHLY', label: 'Monthly' },
{ key: 'CUSTOM', label: 'Custom' }
];
const DAYS = [
{ key: 'MO', label: 'Mo' },
{ key: 'TU', label: 'Tu' },
{ key: 'WE', label: 'We' },
{ key: 'TH', label: 'Th' },
{ key: 'FR', label: 'Fr' },
{ key: 'SA', label: 'Sa' },
{ key: 'SU', label: 'Su' }
];
const buildVisualRrule = (): string => {
let parts = [`FREQ=${lastVisualFrequency}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (lastVisualFrequency === 'WEEKLY' && selectedDays.length) {
parts.push(`BYDAY=${selectedDays.join(',')}`);
}
if (lastVisualFrequency === 'MONTHLY') {
parts.push(`BYMONTHDAY=${monthDay}`);
}
if (['DAILY', 'WEEKLY', 'MONTHLY'].includes(lastVisualFrequency)) {
parts.push(`BYHOUR=${hour}`);
}
parts.push(`BYMINUTE=${minute}`);
return `RRULE:${parts.join(';')}`;
};
let lastVisualFrequency = 'DAILY';
let prevFrequency = 'DAILY';
$: if (frequency !== 'CUSTOM') {
lastVisualFrequency = frequency;
}
$: {
if (frequency === 'CUSTOM' && prevFrequency !== 'CUSTOM') {
customRrule = buildVisualRrule();
}
prevFrequency = frequency;
}
const buildRrule = (): string => {
if (frequency === 'CUSTOM') return customRrule;
let parts = [`FREQ=${frequency}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (frequency === 'WEEKLY' && selectedDays.length) {
parts.push(`BYDAY=${selectedDays.join(',')}`);
}
if (frequency === 'MONTHLY') {
parts.push(`BYMONTHDAY=${monthDay}`);
}
if (['DAILY', 'WEEKLY', 'MONTHLY'].includes(frequency)) {
parts.push(`BYHOUR=${hour}`);
}
parts.push(`BYMINUTE=${minute}`);
return `RRULE:${parts.join(';')}`;
};
const parseRrule = (s: string) => {
const parts: Record<string, string> = {};
s.replace('RRULE:', '')
.split(';')
.forEach((p) => {
const [k, v] = p.split('=');
if (k && v) parts[k] = v;
});
const freq = parts.FREQ || 'DAILY';
if (!['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) {
frequency = 'CUSTOM';
customRrule = s;
return;
}
frequency = freq;
interval = parseInt(parts.INTERVAL || '1');
hour = parseInt(parts.BYHOUR || '9');
minute = parseInt(parts.BYMINUTE || '0');
selectedDays = parts.BYDAY ? parts.BYDAY.split(',') : [];
monthDay = parseInt(parts.BYMONTHDAY || '1');
};
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 === 'HOURLY') return 'Hourly';
if (freq === 'DAILY') return 'Daily';
if (freq === 'WEEKLY') return 'Weekly';
if (freq === 'MONTHLY') return 'Monthly';
if (freq === 'CUSTOM') return 'Custom';
return 'Schedule';
};
const submitHandler = async () => {
if (!name.trim() || !prompt.trim() || !model_id.trim()) {
toast.error($i18n.t('Name, prompt, and model are required'));
return;
}
loading = true;
try {
const form: AutomationForm = {
name: name.trim(),
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: buildRrule()
},
is_active
};
if (automation) {
await updateAutomationById(localStorage.token, automation.id, form);
toast.success($i18n.t('Automation updated'));
} else {
await createAutomation(localStorage.token, form);
toast.success($i18n.t('Automation created'));
}
show = false;
dispatch('save');
} catch (e: any) {
toast.error(e?.detail ?? `${e}` ?? 'Failed to save');
} finally {
loading = false;
}
};
const init = () => {
if (automation) {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
is_active = automation.is_active;
parseRrule(automation.data.rrule);
} else {
name = '';
prompt = '';
model_id = '';
is_active = true;
frequency = 'DAILY';
interval = 1;
hour = 9;
minute = 0;
selectedDays = [];
monthDay = 1;
}
showScheduleDropdown = false;
};
$: if (show) {
init();
}
</script>
<Modal size="md" bind:show>
<div>
<!-- Header -->
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<input
class="w-full text-lg font-medium bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
type="text"
bind:value={name}
placeholder={$i18n.t('Automation title')}
/>
<button
class="self-center shrink-0 ml-2"
aria-label={$i18n.t('Close')}
on:click={() => (show = false)}
>
<XMark className="size-5" />
</button>
</div>
<!-- Prompt -->
<div class="px-5 pb-2">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Instructions')}</div>
<textarea
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 resize-none min-h-[12rem]"
bind:value={prompt}
rows={8}
placeholder={$i18n.t('Enter prompt here.')}
/>
</div>
<!-- Bottom toolbar -->
<div class="flex items-center justify-between px-4 pb-3.5 pt-1 gap-2">
<div class="flex items-center gap-0.5 flex-wrap flex-1 min-w-0">
<!-- Schedule dropdown -->
<Dropdown bind:show={showScheduleDropdown} side="top" align="start">
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl text-xs transition
text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span class="whitespace-nowrap"
>{scheduleLabel(frequency, interval, hour, minute, selectedDays, monthDay)}</span
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div
slot="content"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Schedule')}
</div>
<div class="flex gap-1 px-2 mb-2 overflow-x-auto scrollbar-none">
{#each FREQUENCIES as f}
<button
type="button"
class="px-2 py-1 rounded-xl text-xs transition shrink-0 {frequency === f.key
? 'bg-gray-50 dark:bg-gray-800 text-black dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}"
on:click={() => (frequency = f.key)}
>
{f.label}
</button>
{/each}
</div>
{#if frequency === 'CUSTOM'}
<div class="px-2 pb-2">
<input
type="text"
bind:value={customRrule}
placeholder="RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0"
class="w-full bg-transparent outline-hidden text-xs placeholder:text-gray-400 dark:placeholder:text-gray-600"
on:click={(e) => e.stopPropagation()}
/>
</div>
{:else}
<div class="flex gap-2 flex-wrap items-center px-3 pb-2 text-xs">
{#if frequency === 'HOURLY'}
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500">{$i18n.t('Every')}</span>
<input
type="number"
bind:value={interval}
min={1}
max={60}
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"
on:click={(e) => e.stopPropagation()}
/>
<span class="text-xs text-gray-500">hr</span>
<span class="text-xs text-gray-500">{$i18n.t('at')}</span>
<span class="text-gray-400">:</span>
<input
type="number"
bind:value={minute}
min={0}
max={59}
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"
on:click={(e) => e.stopPropagation()}
/>
<span class="text-xs text-gray-500">min</span>
</div>
{:else}
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500 mr-0.5">{$i18n.t('Time')}</span>
<input
type="time"
value={`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`}
on:input={(e) => {
const [h, m] = e.currentTarget.value.split(':').map(Number);
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"
on:click={(e) => e.stopPropagation()}
/>
</div>
{/if}
{#if frequency === 'MONTHLY'}
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500">{$i18n.t('Day')}</span>
<input
type="number"
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"
on:click={(e) => e.stopPropagation()}
/>
</div>
{/if}
</div>
{#if frequency === 'WEEKLY'}
<div class="flex gap-1 px-2 pb-2">
{#each DAYS as d}
<button
type="button"
class="flex-1 py-1 text-xs rounded-xl transition {selectedDays.includes(d.key)
? 'bg-gray-50 dark:bg-gray-800 text-black dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}"
on:click={() => {
if (selectedDays.includes(d.key)) {
selectedDays = selectedDays.filter((x) => x !== d.key);
} else {
selectedDays = [...selectedDays, d.key];
}
}}
>
{d.label}
</button>
{/each}
</div>
{/if}
{/if}
</div>
</Dropdown>
<!-- Model dropdown -->
<Dropdown bind:show={showModelDropdown} side="top" align="start">
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl text-xs transition
text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3.5 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"
/>
</svg>
<span class="whitespace-nowrap max-w-32 truncate">{modelLabel}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div
slot="content"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
>
<div class="flex items-center gap-2 px-2.5 py-1.5">
<Search className="size-3.5" strokeWidth="2.5" />
<input
bind:value={modelSearch}
class="w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Search a model')}
autocomplete="off"
on:click={(e) => e.stopPropagation()}
/>
</div>
<div class="overflow-y-auto scrollbar-thin max-h-60">
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Models')}
</div>
{#each filteredModels as model (model.id)}
<button
class="px-2.5 py-1.5 rounded-xl w-full text-left text-sm {model_id === model.id
? 'bg-gray-50 dark:bg-gray-800'
: ''}"
type="button"
on:click={() => {
model_id = model.id;
showModelDropdown = false;
modelSearch = '';
}}
>
<div class="flex text-black dark:text-gray-100 line-clamp-1">
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${encodeURIComponent(model.id)}`}
alt={model?.name ?? model.id}
class="rounded-full size-5 items-center mr-2"
loading="lazy"
on:error={(e) => {
e.currentTarget.src = '/favicon.png';
}}
/>
<div class="truncate">
{model.name}
</div>
</div>
</button>
{:else}
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
{/each}
</div>
</div>
</Dropdown>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 transition"
type="button"
on:click={() => (show = false)}
>
{$i18n.t('Cancel')}
</button>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
? 'cursor-not-allowed'
: ''}"
on:click={submitHandler}
type="button"
disabled={loading}
>
{automation ? $i18n.t('Save') : $i18n.t('Create')}
{#if loading}
<span class="shrink-0"><Spinner /></span>
{/if}
</button>
</div>
</div>
</div>
</Modal>
@@ -0,0 +1,101 @@
<script lang="ts">
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let editHandler: Function;
export let runHandler: Function = () => {};
export let deleteHandler: Function;
export let onClose: Function = () => {};
let show = false;
</script>
<Dropdown
bind:show
onOpenChange={(state) => {
if (state === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<div
class="min-w-[170px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
draggable="false"
on:click={() => {
editHandler();
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
draggable="false"
on:click={() => {
runHandler();
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
<div class="flex items-center">{$i18n.t('Run Now')}</div>
</button>
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
draggable="false"
on:click={() => {
deleteHandler();
show = false;
}}
>
<GarbageBin />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
</div>
</div>
</Dropdown>
+2 -1
View File
@@ -175,7 +175,8 @@
bind:this={contentEl}
class={contentClass}
transition:flyAndScale
on:click|stopPropagation
on:click={(e) => e.stopPropagation()}
on:pointerdown={(e) => e.stopPropagation()}
>
<slot name="content" />
</div>
+28 -1
View File
@@ -55,6 +55,9 @@
mounted = true;
});
let handleOutsidePointerDown;
let handleModalFocusIn;
$: if (show && modalElement) {
document.body.appendChild(modalElement);
focusTrap = FocusTrap.createFocusTrap(modalElement, {
@@ -66,10 +69,34 @@
}
});
focusTrap.activate();
// Auto-pause focus trap when interacting with portaled content (e.g. Dropdown)
handleOutsidePointerDown = (e) => {
if (focusTrap && modalElement && !modalElement.contains(e.target)) {
focusTrap.pause();
}
};
handleModalFocusIn = () => {
if (focusTrap) {
focusTrap.unpause();
}
};
document.addEventListener('pointerdown', handleOutsidePointerDown, true);
modalElement.addEventListener('focusin', handleModalFocusIn);
window.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
} else if (modalElement) {
focusTrap.deactivate();
if (focusTrap) {
focusTrap.deactivate();
focusTrap = null;
}
if (handleOutsidePointerDown) {
document.removeEventListener('pointerdown', handleOutsidePointerDown, true);
}
if (handleModalFocusIn) {
modalElement.removeEventListener('focusin', handleModalFocusIn);
}
window.removeEventListener('keydown', handleKeyDown);
document.body.removeChild(modalElement);
document.body.style.overflow = 'unset';
+3 -1
View File
@@ -536,7 +536,7 @@
};
});
// Handler for chat:active events (defined outside onMount for proper cleanup)
// Handler for chat events (defined outside onMount for proper cleanup)
const chatActiveEventHandler = (event: {
chat_id: string;
message_id: string;
@@ -553,6 +553,8 @@
}
return newSet;
});
} else if (event.data?.type === 'chat:list') {
initChatList();
}
};
@@ -214,6 +214,40 @@
<div class=" self-center truncate">{$i18n.t('Settings')}</div>
</button>
<a
href="/automations"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/automations');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Automations')}</div>
</a>
<button
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
type="button"
+372
View File
@@ -0,0 +1,372 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { WEBUI_NAME, mobile, showSidebar } from '$lib/stores';
import {
getAutomations,
toggleAutomationById,
runAutomationById,
deleteAutomationById,
type AutomationResponse
} from '$lib/apis/automations';
import AutomationModal from '$lib/components/AutomationModal.svelte';
import AutomationMenu from '$lib/components/automations/AutomationMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
import Search from '$lib/components/icons/Search.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Select from '$lib/components/common/Select.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
const i18n = getContext('i18n');
let loaded = false;
let automations: AutomationResponse[] = [];
let showEditor = false;
let editingAutomation: AutomationResponse | null = null;
let showDeleteConfirm = false;
let deleteTarget: AutomationResponse | null = null;
let query = '';
let statusFilter = 'all';
const getFilteredAutomations = (list, q, status) => {
let filtered = list;
if (status === 'active') {
filtered = filtered.filter((a) => a.is_active);
} else if (status === 'paused') {
filtered = filtered.filter((a) => !a.is_active);
}
if (q) {
const lower = q.toLowerCase();
filtered = filtered.filter((a) =>
a.name.toLowerCase().includes(lower) ||
a.data.prompt.toLowerCase().includes(lower)
);
}
return filtered;
};
const getAutomationList = async () => {
const res = await getAutomations(localStorage.token).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
automations = res;
}
};
const toggleHandler = async (automation: AutomationResponse) => {
const res = await toggleAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
automations = automations.map((a) => (a.id === res.id ? res : a));
}
};
const runNowHandler = async (automation: AutomationResponse) => {
const res = await runAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
toast.success($i18n.t('Automation triggered'));
}
};
const deleteHandler = async (automation: AutomationResponse) => {
const res = await deleteAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
toast.success($i18n.t(`Deleted {{name}}`, { name: automation.name }));
automations = automations.filter((a) => a.id !== automation.id);
}
};
const formatRRule = (rrule: string): string => {
const parts: Record<string, string> = {};
rrule
.replace('RRULE:', '')
.split(';')
.forEach((p) => {
const [k, v] = p.split('=');
if (k && v) parts[k] = v;
});
const freq = parts.FREQ || '';
const hour = parseInt(parts.BYHOUR || '0');
const min = (parts.BYMINUTE || '0').padStart(2, '0');
const iv = parseInt(parts.INTERVAL || '1');
const ampm = hour >= 12 ? 'PM' : 'AM';
const h12 = hour % 12 || 12;
const time = `${h12}:${min} ${ampm}`;
if (freq === 'MINUTELY') return iv === 1 ? 'Every minute' : `Every ${iv} minutes`;
if (freq === 'HOURLY') return iv === 1 ? 'Hourly' : `Every ${iv} hours`;
if (freq === 'DAILY') return `Daily at ${time}`;
if (freq === 'WEEKLY') {
const days = parts.BYDAY || '';
return days ? `${days} at ${time}` : `Weekly at ${time}`;
}
if (freq === 'MONTHLY') return `Monthly on the ${parts.BYMONTHDAY || '1'}${ordinal(parts.BYMONTHDAY || '1')} at ${time}`;
return rrule;
};
const ordinal = (n: string): string => {
const num = parseInt(n);
if (num % 10 === 1 && num !== 11) return 'st';
if (num % 10 === 2 && num !== 12) return 'nd';
if (num % 10 === 3 && num !== 13) return 'rd';
return 'th';
};
const relativeTime = (ns: number): string => {
const diff = Date.now() - ns / 1_000_000;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
onMount(async () => {
await getAutomationList();
loaded = true;
});
</script>
<svelte:head>
<title>{$i18n.t('Automations')} • {$WEBUI_NAME}</title>
</svelte:head>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete automation?')}
on:confirm={() => {
if (deleteTarget) deleteHandler(deleteTarget);
}}
>
<div class="text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class="font-medium">{deleteTarget?.name}</span>.
</div>
</DeleteConfirmDialog>
<AutomationModal
bind:show={showEditor}
automation={editingAutomation}
on:save={() => {
editingAutomation = null;
getAutomationList();
}}
/>
<div
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full"
>
<div class="flex-1 max-h-full overflow-y-auto">
<div class="pb-1 px-3 md:px-[18px] pt-1.5">
{#if loaded}
<div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
<div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
{#if $mobile}
<Tooltip
content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
>
<button
id="sidebar-toggle-button"
class="cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
>
<div class="self-center p-1.5">
<SidebarIcon />
</div>
</button>
</Tooltip>
{/if}
<div>{$i18n.t('Automations')}</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{automations.length}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
<button
class="px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
on:click={() => {
editingAutomation = null;
showEditor = true;
}}
>
<Plus className="size-3" strokeWidth="2.5" />
<div class="hidden md:block md:ml-1 text-xs">
{$i18n.t('New Automation')}
</div>
</button>
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<div class="flex flex-1 items-center">
<div class="self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class="w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
aria-label={$i18n.t('Search Automations')}
placeholder={$i18n.t('Search Automations')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
<div class="px-3 flex w-full bg-transparent overflow-x-auto scrollbar-none -mx-1">
<div class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap">
<Select
bind:value={statusFilter}
items={[
{ value: 'all', label: $i18n.t('All') },
{ value: 'active', label: $i18n.t('Active') },
{ value: 'paused', label: $i18n.t('Paused') }
]}
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
</div>
</div>
{#if getFilteredAutomations(automations, query, statusFilter).length === 0}
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class="text-3xl mb-3">⚡</div>
<div class="text-lg font-medium mb-1">
{query ? $i18n.t('No results found') : $i18n.t('No automations found')}
</div>
<div class="text-gray-500 text-center text-xs">
{query
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Create scheduled prompts that run automatically on a recurring basis.')}
</div>
</div>
</div>
{:else}
<div class="gap-2 grid my-2 px-3">
{#each getFilteredAutomations(automations, query, statusFilter) as automation (automation.id)}
<div
class="flex space-x-4 text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="flex-1 cursor-pointer"
on:click={() => {
editingAutomation = automation;
showEditor = true;
}}
>
<div class="line-clamp-1 text-sm">{automation.name}</div>
<div class="text-xs text-gray-500 line-clamp-1">
{formatRRule(automation.data.rrule)}
</div>
</div>
<div class="flex flex-row gap-0.5 self-center">
<AutomationMenu
editHandler={() => {
editingAutomation = automation;
showEditor = true;
}}
runHandler={() => {
runNowHandler(automation);
}}
deleteHandler={() => {
deleteTarget = automation;
showDeleteConfirm = true;
}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</AutomationMenu>
<button on:click={(e) => { e.stopPropagation(); e.preventDefault(); }}>
<Tooltip
content={automation.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={automation.is_active}
on:change={() => {
toggleHandler(automation);
}}
/>
</Tooltip>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
{/if}
</div>
</div>
</div>