This commit is contained in:
Timothy Jaeryang Baek
2026-04-01 00:55:52 -05:00
parent c6b1c56e9e
commit fe8a3d9f83
4 changed files with 202 additions and 42 deletions
+48 -1
View File
@@ -4,7 +4,7 @@ from typing import Optional
from uuid import uuid4 from uuid import uuid4
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context from open_webui.internal.db import Base, get_db, get_db_context
@@ -108,6 +108,11 @@ class AutomationResponse(AutomationModel):
next_runs: Optional[list[int]] = None next_runs: Optional[list[int]] = None
class AutomationListResponse(BaseModel):
items: list[AutomationModel]
total: int
#################### ####################
# AutomationTable # AutomationTable
#################### ####################
@@ -159,6 +164,48 @@ class AutomationTable:
) )
return [AutomationModel.model_validate(r) for r in rows] return [AutomationModel.model_validate(r) for r in rows]
def search_automations(
self,
user_id: str,
query: Optional[str] = None,
status: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> 'AutomationListResponse':
with get_db_context(db) as db:
q = db.query(Automation).filter_by(user_id=user_id)
if query:
search = f'%{query}%'
# Search in name and prompt inside JSON data
q = q.filter(
or_(
Automation.name.ilike(search),
cast(Automation.data, String).ilike(search),
)
)
if status == 'active':
q = q.filter(Automation.is_active == True)
elif status == 'paused':
q = q.filter(Automation.is_active == False)
q = q.order_by(Automation.created_at.desc())
total = q.count()
if skip:
q = q.offset(skip)
if limit:
q = q.limit(limit)
rows = q.all()
return AutomationListResponse(
items=[AutomationModel.model_validate(r) for r in rows],
total=total,
)
def get_all(self, db: Optional[Session] = None) -> list[AutomationModel]: def get_all(self, db: Optional[Session] = None) -> list[AutomationModel]:
with get_db_context(db) as db: with get_db_context(db) as db:
rows = ( rows = (
+40
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,6 +12,7 @@ from open_webui.models.automations import (
AutomationModel, AutomationModel,
AutomationResponse, AutomationResponse,
AutomationRunModel, AutomationRunModel,
AutomationListResponse,
) )
from open_webui.utils.automations import ( from open_webui.utils.automations import (
validate_rrule, validate_rrule,
@@ -26,6 +28,8 @@ log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
PAGE_ITEM_COUNT = 30
############################ ############################
# Helpers # Helpers
@@ -71,6 +75,42 @@ async def get_automations(
return [enrich_automation(automation, db, tz=user.timezone) for automation in automations] return [enrich_automation(automation, db, tz=user.timezone) for automation in automations]
############################
# GetAutomationItems (paginated)
############################
@router.get('/list')
async def get_automation_items(
request: Request,
query: Optional[str] = None,
status: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
result = Automations.search_automations(
user_id=user.id,
query=query,
status=status,
skip=skip,
limit=limit,
db=db,
)
return {
'items': [
enrich_automation(item, db, tz=user.timezone)
for item in result.items
],
'total': result.total,
}
############################ ############################
# CreateNewAutomation # CreateNewAutomation
############################ ############################
+44
View File
@@ -77,6 +77,50 @@ export const getAutomations = async (token: string) => {
return res; return res;
}; };
export const getAutomationItems = async (
token: string,
query: string | null,
status: string | null,
page: number
): Promise<{ items: AutomationResponse[]; total: number }> => {
let error = null;
const searchParams = new URLSearchParams();
if (query) {
searchParams.append('query', query);
}
if (status && status !== 'all') {
searchParams.append('status', status);
}
if (page) {
searchParams.append('page', page.toString());
}
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/list?${searchParams.toString()}`, {
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) => { export const createAutomation = async (token: string, form: AutomationForm) => {
let error = null; let error = null;
+70 -41
View File
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount, getContext } from 'svelte'; import { onMount, onDestroy, getContext } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { WEBUI_NAME, mobile, showSidebar } from '$lib/stores'; import { WEBUI_NAME, mobile, showSidebar } from '$lib/stores';
import { import {
getAutomations, getAutomationItems,
toggleAutomationById, toggleAutomationById,
runAutomationById, runAutomationById,
deleteAutomationById, deleteAutomationById,
@@ -17,6 +17,7 @@
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte'; import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
@@ -30,7 +31,9 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
let loaded = false; let loaded = false;
let automations: AutomationResponse[] = []; let automations: AutomationResponse[] | null = null;
let total: number | null = null;
let loading = false;
let showEditor = false; let showEditor = false;
let editingAutomation: AutomationResponse | null = null; let editingAutomation: AutomationResponse | null = null;
@@ -40,31 +43,48 @@
let query = ''; let query = '';
let statusFilter = 'all'; let statusFilter = 'all';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
const getFilteredAutomations = (list, q, status) => { let page = 1;
let filtered = list;
if (status === 'active') { // Debounce only query changes
filtered = filtered.filter((a) => a.is_active); $: if (query !== undefined) {
} else if (status === 'paused') { loading = true;
filtered = filtered.filter((a) => !a.is_active); clearTimeout(searchDebounceTimer);
} searchDebounceTimer = setTimeout(() => {
if (q) { page = 1;
const lower = q.toLowerCase(); getAutomationList();
filtered = filtered.filter((a) => }, 300);
a.name.toLowerCase().includes(lower) || }
a.data.prompt.toLowerCase().includes(lower)
); // Immediate response to page/filter changes
} $: if (page && statusFilter !== undefined) {
return filtered; getAutomationList();
}; }
const getAutomationList = async () => { const getAutomationList = async () => {
const res = await getAutomations(localStorage.token).catch((err) => { if (!loaded) return;
toast.error(`${err}`);
return null; loading = true;
}); try {
if (res) { const res = await getAutomationItems(
automations = res; localStorage.token,
query,
statusFilter,
page
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
automations = res.items;
total = res.total;
}
} catch (err) {
console.error(err);
} finally {
loading = false;
} }
}; };
@@ -74,7 +94,7 @@
return null; return null;
}); });
if (res) { if (res) {
automations = automations.map((a) => (a.id === res.id ? res : a)); automations = (automations ?? []).map((a) => (a.id === res.id ? res : a));
} }
}; };
@@ -95,8 +115,10 @@
}); });
if (res) { if (res) {
toast.success($i18n.t(`Deleted {{name}}`, { name: automation.name })); toast.success($i18n.t(`Deleted {{name}}`, { name: automation.name }));
automations = automations.filter((a) => a.id !== automation.id);
} }
page = 1;
getAutomationList();
}; };
const formatRRule = (rrule: string): string => { const formatRRule = (rrule: string): string => {
@@ -144,19 +166,16 @@
return 'th'; 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 () => { onMount(async () => {
await getAutomationList();
loaded = true; loaded = true;
return () => {
clearTimeout(searchDebounceTimer);
};
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
}); });
</script> </script>
@@ -215,7 +234,7 @@
{/if} {/if}
<div>{$i18n.t('Automations')}</div> <div>{$i18n.t('Automations')}</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500"> <div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{automations.length} {total ?? ''}
</div> </div>
</div> </div>
@@ -296,7 +315,11 @@
</div> </div>
</div> </div>
{#if getFilteredAutomations(automations, query, statusFilter).length === 0} {#if automations === null || loading}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
{:else if (automations ?? []).length === 0}
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24"> <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="max-w-md text-center">
<div class="text-3xl mb-3">⚡</div> <div class="text-3xl mb-3">⚡</div>
@@ -312,7 +335,7 @@
</div> </div>
{:else} {:else}
<div class="gap-2 grid my-2 px-3"> <div class="gap-2 grid my-2 px-3">
{#each getFilteredAutomations(automations, query, statusFilter) as automation (automation.id)} {#each automations as automation (automation.id)}
<div <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" 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"
> >
@@ -369,6 +392,12 @@
</div> </div>
{/each} {/each}
</div> </div>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
</div>
{/if}
{/if} {/if}
</div> </div>
</div> </div>