feat: automation
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user