This commit is contained in:
Timothy Jaeryang Baek
2026-08-13 16:42:10 -06:00
parent 794671a988
commit 2649e3305c
13 changed files with 608 additions and 89 deletions
+6
View File
@@ -5,11 +5,17 @@ export type AutomationTerminalConfig = {
cwd?: string;
};
export type AutomationTarget = {
type: 'chat' | 'channel';
channel_id?: string | null;
};
export type AutomationData = {
prompt: string;
model_id: string;
rrule: string;
terminal?: AutomationTerminalConfig;
target?: AutomationTarget | null;
};
export type AutomationForm = {
+37 -5
View File
@@ -8,9 +8,10 @@
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
import FolderDropdown from '$lib/components/automations/FolderDropdown.svelte';
import DestinationDropdown from '$lib/components/automations/DestinationDropdown.svelte';
import { getFolders } from '$lib/apis/folders';
import { folders } from '$lib/stores';
import { getChannels } from '$lib/apis/channels';
import { channels, folders } from '$lib/stores';
import {
createAutomation,
@@ -30,10 +31,13 @@
let prompt = '';
let model_id = '';
let folder_id = '';
let target_type: 'chat' | 'channel' = 'chat';
let channel_id = '';
let is_active = true;
let loading = false;
let foldersLoaded = false;
let channelsLoaded = false;
// Schedule dropdown ref
let scheduleDropdown: ScheduleDropdown;
@@ -43,6 +47,10 @@
toast.error($i18n.t('Name, prompt, and model are required'));
return;
}
if (target_type === 'channel' && !channel_id) {
toast.error($i18n.t('Channel is required'));
return;
}
if (scheduleDropdown?.frequency === 'ONCE') {
const scheduled = new Date(`${scheduleDropdown.onceDate}T${scheduleDropdown.onceTime}`);
if (scheduled <= new Date()) {
@@ -54,11 +62,12 @@
try {
const form: AutomationForm = {
name: name.trim(),
folder_id: folder_id || null,
folder_id: target_type === 'channel' ? null : folder_id || null,
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: scheduleDropdown.buildRrule()
rrule: scheduleDropdown.buildRrule(),
target: target_type === 'channel' ? { type: 'channel', channel_id } : { type: 'chat' }
},
is_active
};
@@ -88,12 +97,19 @@
if (res) folders.set(res);
foldersLoaded = true;
}
if (!channelsLoaded && ($channels ?? []).length === 0) {
const res = await getChannels(localStorage.token).catch(() => null);
if (res) channels.set(res);
channelsLoaded = true;
}
if (automation) {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
folder_id = automation.folder_id ?? '';
target_type = automation.data.target?.type === 'channel' ? 'channel' : 'chat';
channel_id = automation.data.target?.channel_id ?? '';
is_active = automation.is_active;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
@@ -105,6 +121,12 @@
folder_id = ($folders ?? []).some((folder) => folder.id === cloneFrom.folder_id)
? (cloneFrom.folder_id ?? '')
: '';
target_type = cloneFrom.data.target?.type === 'channel' ? 'channel' : 'chat';
channel_id = ($channels ?? []).some(
(channel) => channel.id === cloneFrom.data.target?.channel_id
)
? (cloneFrom.data.target?.channel_id ?? '')
: '';
is_active = true;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(cloneFrom.data.rrule);
@@ -114,6 +136,8 @@
prompt = '';
model_id = '';
folder_id = '';
target_type = 'chat';
channel_id = '';
is_active = true;
}
};
@@ -162,7 +186,15 @@
<ModelDropdown bind:model_id side="top" align="start" />
<FolderDropdown bind:folder_id side="top" align="start" />
<DestinationDropdown
bind:target_type
bind:channel_id
bind:folder_id
folders={$folders}
channels={$channels}
side="top"
align="start"
/>
</div>
<div class="flex items-center justify-end gap-2 shrink-0">
@@ -7,8 +7,9 @@
import localizedFormat from 'dayjs/plugin/localizedFormat';
import type i18nType from '$lib/i18n';
import { WEBUI_NAME, folders } from '$lib/stores';
import { WEBUI_NAME, channels, folders } from '$lib/stores';
import { getFolders } from '$lib/apis/folders';
import { getChannels } from '$lib/apis/channels';
import {
getAutomationById,
@@ -44,6 +45,7 @@
let hasMoreRuns = true;
let runsPage = 0;
let foldersLoaded = false;
let channelsLoaded = false;
const ensureFolders = async () => {
if (foldersLoaded || ($folders ?? []).length > 0) return;
@@ -52,11 +54,29 @@
foldersLoaded = true;
};
const ensureChannels = async () => {
if (channelsLoaded || ($channels ?? []).length > 0) return;
const res = await getChannels(localStorage.token).catch(() => null);
if (res) channels.set(res);
channelsLoaded = true;
};
const getFolderName = (folderId: string | null): string =>
folderId
? (($folders ?? []).find((folder) => folder.id === folderId)?.name ?? $i18n.t('None'))
: $i18n.t('None');
const getDestinationName = (): string => {
const target = automation.data.target;
if (target?.type === 'channel') {
const channel = ($channels ?? []).find((channel) => channel.id === target.channel_id);
return channel?.name ? `#${channel.name}` : $i18n.t('Channel');
}
return automation.folder_id
? `${$i18n.t('Folder')}: ${getFolderName(automation.folder_id)}`
: $i18n.t('New chat');
};
const formatTime = (ts: number | null): string => {
if (!ts) return '-';
return new Date(ts / 1_000_000).toLocaleString(undefined, {
@@ -216,6 +236,7 @@
is_active = automation.is_active;
await ensureFolders();
await ensureChannels();
await loadRuns();
});
@@ -283,10 +304,10 @@
<div class="flex h-7 items-center px-3">
<span class="w-24 shrink-0 text-[0.6875rem] text-gray-400 dark:text-gray-500">
{$i18n.t('Folder')}
{$i18n.t('Destination')}
</span>
<span class="min-w-0 truncate text-xs text-gray-700 dark:text-gray-300">
{getFolderName(automation.folder_id)}
{getDestinationName()}
</span>
</div>
@@ -356,11 +377,19 @@
<button
class="group flex items-center gap-1 text-[0.6875rem] text-gray-400"
on:click={() => {
goto(`/c/${run.chat_id}`);
if (run.chat_id?.startsWith('channel:')) {
goto(`/channels/${run.chat_id.replace('channel:', '')}`);
} else {
goto(`/c/${run.chat_id}`);
}
}}
type="button"
>
<span class="group-hover:underline">{$i18n.t('View chat')}</span>
<span class="group-hover:underline">
{run.chat_id?.startsWith('channel:')
? $i18n.t('View channel')
: $i18n.t('View chat')}
</span>
<ArrowRight className="size-2.5" strokeWidth="2" />
</button>
{/if}
@@ -0,0 +1,308 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fly } from 'svelte/transition';
import { decodeString } from '$lib/utils';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
import Check from '$lib/components/icons/Check.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
import Hashtag from '$lib/components/icons/Hashtag.svelte';
import Search from '$lib/components/icons/Search.svelte';
const i18n = getContext('i18n');
export let target_type: 'chat' | 'channel' = 'chat';
export let channel_id = '';
export let folder_id = '';
export let folders: any[] = [];
export let channels: any[] = [];
export let side: 'top' | 'bottom' = 'top';
export let align: 'start' | 'end' = 'start';
export let onChange: () => void = () => {};
let show = false;
let tab: '' | 'folders' | 'channels' = '';
let folderSearch = '';
let channelSearch = '';
const folderName = (folder: any) => decodeString(folder?.name ?? $i18n.t('Folder'));
const channelName = (channel: any) => channel?.name || $i18n.t('Channel');
$: folderOptions = [...((folders ?? []) as any[])]
.filter((folder) => folder?.id && !folder?.shared)
.sort((a, b) => folderName(a).localeCompare(folderName(b)));
$: folderById = new Map(folderOptions.map((folder) => [folder.id, folder]));
$: channelOptions = (channels ?? [])
.filter((channel) => channel?.id && channel.type !== 'dm')
.sort((a, b) => channelName(a).localeCompare(channelName(b)));
$: selectedFolder = folderOptions.find((folder) => folder.id === folder_id);
$: selectedChannel = channelOptions.find((channel) => channel.id === channel_id);
const folderPath = (folder: any) => {
const names: string[] = [];
const seen = new Set<string>();
let current = folder;
while (current?.parent_id && !seen.has(current.parent_id)) {
seen.add(current.parent_id);
const parent = folderById.get(current.parent_id);
if (!parent) break;
names.unshift(folderName(parent));
current = parent;
}
return names.join(' / ');
};
$: destinationLabel =
target_type === 'channel'
? selectedChannel
? `#${channelName(selectedChannel)}`
: $i18n.t('Choose channel')
: selectedFolder
? folderName(selectedFolder)
: $i18n.t('New chat');
$: normalizedFolderSearch = folderSearch.trim().toLowerCase();
$: filteredFolderOptions = normalizedFolderSearch
? folderOptions.filter((folder) =>
`${folderName(folder)} ${folderPath(folder)}`.toLowerCase().includes(normalizedFolderSearch)
)
: folderOptions;
$: normalizedChannelSearch = channelSearch.trim().toLowerCase();
$: filteredChannelOptions = normalizedChannelSearch
? channelOptions.filter((channel) => channelName(channel).toLowerCase().includes(normalizedChannelSearch))
: channelOptions;
const selectChat = () => {
target_type = 'chat';
channel_id = '';
folder_id = '';
show = false;
tab = '';
onChange();
};
const selectFolder = (id: string) => {
target_type = 'chat';
channel_id = '';
folder_id = id;
show = false;
tab = '';
folderSearch = '';
onChange();
};
const selectChannel = (id: string) => {
target_type = 'channel';
channel_id = id;
folder_id = '';
show = false;
tab = '';
channelSearch = '';
onChange();
};
</script>
<Dropdown
bind:show
{side}
{align}
onOpenChange={(state) => {
if (!state) {
tab = '';
folderSearch = '';
channelSearch = '';
}
}}
>
<button
type="button"
class="relative h-8 max-w-[11rem] flex items-center gap-1.5 px-2.5 py-1.5 bg-transparent rounded-2xl text-xs font-normal text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
>
{#if target_type === 'channel'}
<Hashtag className="size-3.5 shrink-0" />
{:else if folder_id}
<Folder className="size-3.5 shrink-0" />
{:else}
<ChatBubble className="size-3.5 shrink-0" />
{/if}
<span class="min-w-0 truncate">{destinationLabel}</span>
<ChevronDown className="size-2.5 shrink-0" strokeWidth="2.5" />
</button>
<div slot="content">
<DropdownMenu className="w-72 max-h-72 overflow-hidden shadow-lg">
{#if tab === ''}
<div class="max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin" in:fly={{ x: -20, duration: 150 }}>
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'chat' && !folder_id
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
on:click={selectChat}
>
<div class="flex min-w-0 items-center gap-1.5">
<ChatBubble className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{$i18n.t('New chat')}</span>
</div>
{#if target_type === 'chat' && !folder_id}
<Check className="size-3.5 shrink-0" strokeWidth="2" />
{/if}
</button>
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'chat' && folder_id
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
on:click={() => (tab = 'folders')}
>
<div class="flex min-w-0 items-center gap-1.5">
<Folder className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">
{selectedFolder ? folderName(selectedFolder) : $i18n.t('Folder')}
</span>
</div>
<div class="flex shrink-0 items-center gap-1 text-gray-500">
{#if target_type === 'chat' && folder_id}
<Check className="size-3.5" strokeWidth="2" />
{/if}
<ChevronRight />
</div>
</button>
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'channel'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
on:click={() => (tab = 'channels')}
>
<div class="flex min-w-0 items-center gap-1.5">
<Hashtag className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">
{selectedChannel ? channelName(selectedChannel) : $i18n.t('Channel')}
</span>
</div>
<div class="flex shrink-0 items-center gap-1 text-gray-500">
{#if target_type === 'channel'}
<Check className="size-3.5" strokeWidth="2" />
{/if}
<ChevronRight />
</div>
</button>
</div>
{:else if tab === 'folders'}
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button
type="button"
class="flex h-[1.6875rem] w-full shrink-0 cursor-pointer items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => (tab = '')}
>
<ChevronLeft />
<span>{$i18n.t('Folders')}</span>
</button>
<div class="flex shrink-0 items-center gap-1.5 px-2 py-1">
<Search className="size-3.5 shrink-0" strokeWidth="2.5" />
<input
bind:value={folderSearch}
class="w-full bg-transparent text-[0.8125rem] outline-hidden"
placeholder={$i18n.t('Search folders')}
autocomplete="off"
on:click|stopPropagation
/>
</div>
<div class="overflow-y-auto scrollbar-thin">
{#each filteredFolderOptions as folder (folder.id)}
{@const path = folderPath(folder)}
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'chat' && folder_id === folder.id
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
title={path ? `${path} / ${folderName(folder)}` : folderName(folder)}
on:click={() => selectFolder(folder.id)}
>
<div class="flex min-w-0 items-center gap-1.5">
<Folder className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{folderName(folder)}</span>
{#if path}
<span class="min-w-0 truncate text-[0.6875rem] text-gray-400 dark:text-gray-500">
{path}
</span>
{/if}
</div>
{#if target_type === 'chat' && folder_id === folder.id}
<Check className="size-3.5 shrink-0" strokeWidth="2" />
{/if}
</button>
{:else}
<div class="px-2 py-1 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{folderOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No folders')}
</div>
{/each}
</div>
</div>
{:else if tab === 'channels'}
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button
type="button"
class="flex h-[1.6875rem] w-full shrink-0 cursor-pointer items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => (tab = '')}
>
<ChevronLeft />
<span>{$i18n.t('Channels')}</span>
</button>
<div class="flex shrink-0 items-center gap-1.5 px-2 py-1">
<Search className="size-3.5 shrink-0" strokeWidth="2.5" />
<input
bind:value={channelSearch}
class="w-full bg-transparent text-[0.8125rem] outline-hidden"
placeholder={$i18n.t('Search channels')}
autocomplete="off"
on:click|stopPropagation
/>
</div>
<div class="overflow-y-auto scrollbar-thin">
{#each filteredChannelOptions as channel (channel.id)}
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'channel' && channel_id === channel.id
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
on:click={() => selectChannel(channel.id)}
>
<div class="flex min-w-0 items-center gap-1.5">
<Hashtag className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{channelName(channel)}</span>
</div>
{#if target_type === 'channel' && channel_id === channel.id}
<Check className="size-3.5 shrink-0" strokeWidth="2" />
{/if}
</button>
{:else}
<div class="px-2 py-1 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{channelOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No channels')}
</div>
{/each}
</div>
</div>
{/if}
</DropdownMenu>
</div>
</Dropdown>
@@ -76,7 +76,7 @@
</div>
<!-- Form -->
<div class="px-5 pb-2 flex flex-col gap-3">
<div class="px-4 pb-2 flex flex-col gap-3">
<!-- Name -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
+1 -6
View File
@@ -1186,7 +1186,7 @@
</div>
<div
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden pt-2.5 pb-2.5"
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden space-y-1.5 pt-2.5 pb-2.5"
on:scroll={(e) => {
if (e.target.scrollTop === 0) {
scrollTop = 0;
@@ -1289,7 +1289,6 @@
<SidebarSection
id="sidebar-models"
bind:open={showPinnedModels}
className="mt-0.5"
name={$i18n.t('Models')}
dragAndDrop={false}
>
@@ -1301,7 +1300,6 @@
<SidebarSection
id="sidebar-pinned-notes"
bind:open={showPinnedNotes}
className="mt-0.5"
name={$i18n.t('Notes')}
dragAndDrop={false}
onAdd={async () => {
@@ -1320,7 +1318,6 @@
<SidebarSection
id="sidebar-channels"
bind:open={showChannels}
className="mt-0.5"
name={$i18n.t('Channels')}
dragAndDrop={false}
onAdd={$user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true)
@@ -1354,7 +1351,6 @@
<SidebarSection
id="sidebar-folders"
bind:open={showFolders}
className="mt-0.5"
name={$i18n.t('Folders')}
onAdd={() => {
showCreateFolderModal = true;
@@ -1406,7 +1402,6 @@
<SidebarSection
id="sidebar-chats"
className="mt-0.5"
name={$i18n.t('Chats')}
on:change={async (e) => {
selectedFolder.set(null);
@@ -43,7 +43,7 @@
</div>
<!-- Form -->
<div class="px-5 pb-2 flex flex-col gap-3">
<div class="px-4 pb-2 flex flex-col gap-3">
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<input
+27 -4
View File
@@ -5,8 +5,9 @@
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { WEBUI_NAME, user, config, folders } from '$lib/stores';
import { WEBUI_NAME, user, config, channels, folders } from '$lib/stores';
import { getFolders } from '$lib/apis/folders';
import { getChannels } from '$lib/apis/channels';
import {
createAutomation,
@@ -67,6 +68,7 @@
let importFiles: FileList | null = null;
let automationsImportInputElement: HTMLInputElement;
let foldersLoaded = false;
let channelsLoaded = false;
const syncHeader = () => {
automationsLayout?.setHeader({
@@ -151,6 +153,13 @@
foldersLoaded = true;
};
const ensureChannels = async () => {
if (channelsLoaded || ($channels ?? []).length > 0) return;
const res = await getChannels(localStorage.token).catch(() => null);
if (res) channels.set(res);
channelsLoaded = true;
};
const toggleHandler = async (automation: AutomationResponse) => {
const res = await toggleAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`);
@@ -216,6 +225,16 @@
: $i18n.t('Never');
};
const formatDestination = (automation: AutomationResponse): string => {
if (automation.data.target?.type === 'channel') {
const channel = ($channels ?? []).find(
(channel) => channel.id === automation.data.target?.channel_id
);
return channel?.name ? `#${channel.name}` : $i18n.t('Channel');
}
return automation.folder_id ? $i18n.t('Folder') : $i18n.t('New chat');
};
const getAllAutomations = async () => {
let currentPage = 1;
let allAutomations: AutomationResponse[] = [];
@@ -341,6 +360,7 @@
loaded = true;
syncHeader();
ensureChannels();
return () => {
clearTimeout(searchDebounceTimer);
@@ -580,11 +600,14 @@
</div>
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[0.6875rem] leading-5 text-gray-500 dark:text-gray-500 md:block"
class="hidden max-w-56 shrink-0 self-center truncate text-right text-[0.6875rem] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip content={formatRRule(automation.data.rrule)} className="min-w-0">
<Tooltip
content={`${formatRRule(automation.data.rrule)} · ${formatDestination(automation)}`}
className="min-w-0"
>
<div class="truncate">
{formatRRule(automation.data.rrule)}
{formatRRule(automation.data.rrule)} · {formatDestination(automation)}
</div>
</Tooltip>
</div>