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
+5
View File
@@ -97,6 +97,7 @@ from open_webui.routers import (
utils,
scim,
terminals,
automations,
)
from open_webui.routers.retrieval import (
@@ -648,6 +649,9 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_usage_pool_cleanup())
asyncio.create_task(periodic_session_pool_cleanup())
from open_webui.utils.automations import automation_worker_loop
asyncio.create_task(automation_worker_loop(app))
if app.state.config.ENABLE_BASE_MODELS_CACHE:
try:
await get_all_models(
@@ -1522,6 +1526,7 @@ if ENABLE_ADMIN_ANALYTICS:
app.include_router(analytics.router, prefix='/api/v1/analytics', tags=['analytics'])
app.include_router(utils.router, prefix='/api/v1/utils', tags=['utils'])
app.include_router(terminals.router, prefix='/api/v1/terminals', tags=['terminals'])
app.include_router(automations.router, prefix='/api/v1/automations', tags=['automations'])
# SCIM 2.0 API for identity management
if ENABLE_SCIM:
@@ -0,0 +1,60 @@
"""add automation tables
Revision ID: d4e5f6a7b8c9
Revises: f1e2d3c4b5a6
Create Date: 2026-03-30
"""
from typing import Union
from alembic import op
import sqlalchemy as sa
revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, None] = "a3dd5bedd151"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"automation",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("data", sa.JSON(), nullable=False),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.text("1"),
),
sa.Column("last_run_at", sa.BigInteger(), nullable=True),
sa.Column("next_run_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
)
op.create_index("ix_automation_next_run", "automation", ["next_run_at"])
op.create_table(
"automation_run",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("automation_id", sa.Text(), nullable=False),
sa.Column("chat_id", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
)
op.create_index(
"ix_automation_run_automation_id",
"automation_run",
["automation_id"],
)
def downgrade():
op.drop_index("ix_automation_run_automation_id")
op.drop_table("automation_run")
op.drop_index("ix_automation_next_run")
op.drop_table("automation")
+323
View File
@@ -0,0 +1,323 @@
import time
import logging
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
log = logging.getLogger(__name__)
####################
# Automation DB Schema
####################
class Automation(Base):
__tablename__ = "automation"
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
name = Column(Text, nullable=False)
data = Column(JSON, nullable=False) # {prompt, model_id, rrule}
meta = Column(JSON, nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
last_run_at = Column(BigInteger, nullable=True)
next_run_at = Column(BigInteger, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
Index("ix_automation_next_run", "next_run_at"),
)
class AutomationRun(Base):
__tablename__ = "automation_run"
id = Column(Text, primary_key=True)
automation_id = Column(Text, nullable=False)
chat_id = Column(Text, nullable=True)
status = Column(Text, nullable=False) # success | error
error = Column(Text, nullable=True)
created_at = Column(BigInteger, nullable=False)
__table_args__ = (
Index("ix_automation_run_automation_id", "automation_id"),
)
####################
# Pydantic Models
####################
class AutomationData(BaseModel):
prompt: str
model_id: str
rrule: str
class AutomationModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
name: str
data: dict
meta: Optional[dict] = None
is_active: bool
last_run_at: Optional[int] = None
next_run_at: Optional[int] = None
created_at: int
updated_at: int
class AutomationRunModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
automation_id: str
chat_id: Optional[str] = None
status: str
error: Optional[str] = None
created_at: int
class AutomationForm(BaseModel):
name: str
data: AutomationData
meta: Optional[dict] = None
is_active: Optional[bool] = True
class AutomationResponse(AutomationModel):
last_run: Optional[AutomationRunModel] = None
next_runs: Optional[list[int]] = None
####################
# AutomationTable
####################
class AutomationTable:
def insert(
self,
user_id: str,
form: AutomationForm,
next_run_at: int,
db: Optional[Session] = None,
) -> AutomationModel:
with get_db_context(db) as db:
now = int(time.time_ns())
row = Automation(
id=str(uuid4()),
user_id=user_id,
name=form.name,
data=form.data.model_dump(),
meta=form.meta,
is_active=form.is_active,
next_run_at=next_run_at,
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
db.refresh(row)
return AutomationModel.model_validate(row)
def get_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[AutomationModel]:
with get_db_context(db) as db:
row = db.get(Automation, id)
return AutomationModel.model_validate(row) if row else None
def get_by_user(
self, user_id: str, db: Optional[Session] = None
) -> list[AutomationModel]:
with get_db_context(db) as db:
rows = (
db.query(Automation)
.filter_by(user_id=user_id)
.order_by(Automation.created_at.desc())
.all()
)
return [AutomationModel.model_validate(r) for r in rows]
def get_all(self, db: Optional[Session] = None) -> list[AutomationModel]:
with get_db_context(db) as db:
rows = (
db.query(Automation)
.order_by(Automation.created_at.desc())
.all()
)
return [AutomationModel.model_validate(r) for r in rows]
def update_by_id(
self,
id: str,
form: AutomationForm,
next_run_at: int,
db: Optional[Session] = None,
) -> Optional[AutomationModel]:
with get_db_context(db) as db:
row = db.get(Automation, id)
if not row:
return None
row.name = form.name
row.data = form.data.model_dump()
row.meta = form.meta
if form.is_active is not None:
row.is_active = form.is_active
row.next_run_at = next_run_at
row.updated_at = int(time.time_ns())
db.commit()
db.refresh(row)
return AutomationModel.model_validate(row)
def toggle(
self,
id: str,
next_run_at: Optional[int],
db: Optional[Session] = None,
) -> Optional[AutomationModel]:
with get_db_context(db) as db:
row = db.get(Automation, id)
if not row:
return None
row.is_active = not row.is_active
row.next_run_at = next_run_at if row.is_active else None
row.updated_at = int(time.time_ns())
db.commit()
db.refresh(row)
return AutomationModel.model_validate(row)
def delete(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
row = db.get(Automation, id)
if not row:
return False
db.delete(row)
db.commit()
return True
def claim_due(
self, now_ns: int, limit: int = 10, db: Optional[Session] = None
) -> list[AutomationModel]:
"""
Atomically claim due automations for execution.
Advances next_run_at immediately so the row can never be
double-claimed. On PostgreSQL, uses FOR UPDATE SKIP LOCKED
for zero-contention distributed work claiming.
"""
with get_db_context(db) as db:
stmt = (
select(Automation)
.where(
Automation.is_active == True,
Automation.next_run_at <= now_ns,
)
.order_by(Automation.next_run_at)
.limit(limit)
)
if db.bind.dialect.name == "postgresql":
stmt = stmt.with_for_update(skip_locked=True)
rows = db.execute(stmt).scalars().all()
from open_webui.utils.automations import next_run_ns
for row in rows:
row.last_run_at = now_ns
row.next_run_at = next_run_ns(row.data.get("rrule", ""))
db.commit()
return [AutomationModel.model_validate(r) for r in rows]
####################
# AutomationRunTable
####################
class AutomationRunTable:
def insert(
self,
automation_id: str,
status: str,
chat_id: Optional[str] = None,
error: Optional[str] = None,
db: Optional[Session] = None,
) -> AutomationRunModel:
with get_db_context(db) as db:
row = AutomationRun(
id=str(uuid4()),
automation_id=automation_id,
chat_id=chat_id,
status=status,
error=error,
created_at=int(time.time_ns()),
)
db.add(row)
db.commit()
db.refresh(row)
return AutomationRunModel.model_validate(row)
def get_latest(
self, automation_id: str, db: Optional[Session] = None
) -> Optional[AutomationRunModel]:
with get_db_context(db) as db:
row = (
db.query(AutomationRun)
.filter_by(automation_id=automation_id)
.order_by(AutomationRun.created_at.desc())
.first()
)
return AutomationRunModel.model_validate(row) if row else None
def get_by_automation(
self,
automation_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[AutomationRunModel]:
with get_db_context(db) as db:
rows = (
db.query(AutomationRun)
.filter_by(automation_id=automation_id)
.order_by(AutomationRun.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [AutomationRunModel.model_validate(r) for r in rows]
def delete_by_automation(
self, automation_id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db:
count = (
db.query(AutomationRun)
.filter_by(automation_id=automation_id)
.delete()
)
db.commit()
return count
Automations = AutomationTable()
AutomationRuns = AutomationRunTable()
+235
View File
@@ -0,0 +1,235 @@
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from open_webui.models.automations import (
Automations,
AutomationRuns,
AutomationForm,
AutomationModel,
AutomationResponse,
AutomationRunModel,
)
from open_webui.utils.automations import (
validate_rrule,
next_run_ns,
next_n_runs_ns,
execute_automation,
)
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.internal.db import get_session
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
router = APIRouter()
############################
# Helpers
############################
def check_automation_access(automation, user):
if not automation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != 'admin' and user.id != automation.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
def enrich_automation(
automation: AutomationModel, db: Session, tz: str = None
) -> AutomationResponse:
last_run = AutomationRuns.get_latest(automation.id, db=db)
return AutomationResponse(
**automation.model_dump(),
last_run=last_run,
next_runs=next_n_runs_ns(automation.data['rrule'], tz=tz),
)
############################
# GetAutomations
############################
@router.get('/', response_model=list[AutomationResponse])
async def get_automations(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
automations = Automations.get_by_user(user.id, db=db)
return [enrich_automation(automation, db, tz=user.timezone) for automation in automations]
############################
# CreateNewAutomation
############################
@router.post('/create', response_model=AutomationResponse)
async def create_new_automation(
request: Request,
form_data: AutomationForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
try:
validate_rrule(form_data.data.rrule)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
tz = user.timezone
automation = Automations.insert(
user.id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db
)
return enrich_automation(automation, db, tz=tz)
############################
# GetAutomationById
############################
@router.get('/{id}', response_model=AutomationResponse)
async def get_automation_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
return enrich_automation(automation, db, tz=user.timezone)
############################
# UpdateAutomationById
############################
@router.post('/{id}/update', response_model=AutomationResponse)
async def update_automation_by_id(
request: Request,
id: str,
form_data: AutomationForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
try:
validate_rrule(form_data.data.rrule)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
tz = user.timezone
updated = Automations.update_by_id(
id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db
)
return enrich_automation(updated, db, tz=tz)
############################
# ToggleAutomationById
############################
@router.post('/{id}/toggle', response_model=AutomationResponse)
async def toggle_automation_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
toggled = Automations.toggle(
id, next_run_ns(automation.data['rrule'], tz=user.timezone), db=db
)
return enrich_automation(toggled, db, tz=user.timezone)
############################
# RunAutomationById
############################
@router.post('/{id}/run')
async def run_automation_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
asyncio.create_task(execute_automation(request.app, automation))
return enrich_automation(automation, db, tz=user.timezone)
############################
# DeleteAutomationById
############################
@router.delete('/{id}/delete')
async def delete_automation_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
AutomationRuns.delete_by_automation(id, db=db)
return Automations.delete(id, db=db)
############################
# GetAutomationRuns
############################
@router.get('/{id}/runs', response_model=list[AutomationRunModel])
async def get_automation_runs(
request: Request,
id: str,
skip: int = 0,
limit: int = 50,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
return AutomationRuns.get_by_automation(id, skip=skip, limit=limit, db=db)
############################
# Admin Endpoints
############################
@router.get('/admin/all', response_model=list[AutomationModel])
async def get_all_automations(
request: Request,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
return Automations.get_all(db=db)
+338
View File
@@ -0,0 +1,338 @@
"""
Automation utilities.
RRULE helpers, worker loop, and execution logic.
Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
Environment:
AUTOMATION_POLL_INTERVAL seconds between polls (default: 10)
"""
import asyncio
import logging
import os
import random
import time
from datetime import datetime
from typing import Optional
from uuid import uuid4
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
from fastapi import Request
from starlette.datastructures import Headers
from open_webui.models.automations import Automations, AutomationRuns, AutomationModel
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.users import Users
from open_webui.utils.task import prompt_template
from open_webui.internal.db import get_db
log = logging.getLogger(__name__)
AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10'))
####################
# RRULE Helpers
####################
def validate_rrule(s: str) -> None:
"""Raise ValueError if the RRULE is malformed or exhausted."""
try:
rule = rrulestr(s, ignoretz=True)
except Exception as e:
raise ValueError(f'Invalid RRULE: {e}')
if rule.after(datetime.now()) is None:
raise ValueError('RRULE has no future occurrences')
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))
if dt is None:
return None
if tz:
dt = dt.replace(tzinfo=ZoneInfo(tz))
return int(dt.timestamp() * 1_000_000_000)
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)
result = []
dt = datetime.now()
for _ in range(n):
dt = rule.after(dt)
if not dt:
break
if tz:
dt_tz = dt.replace(tzinfo=ZoneInfo(tz))
result.append(int(dt_tz.timestamp() * 1_000_000_000))
else:
result.append(int(dt.timestamp() * 1_000_000_000))
return result
############################
# Worker Loop
############################
async def automation_worker_loop(app) -> None:
"""Poll for due automations, claim, fire-and-forget execute.
Runs on every instance. Poll interval is configurable via
AUTOMATION_POLL_INTERVAL env var (default: 10 seconds).
"""
log.info(
f'Automation worker started (poll interval: {AUTOMATION_POLL_INTERVAL}s)'
)
while True:
try:
with get_db() as db:
batch = Automations.claim_due(
int(time.time_ns()), limit=10, db=db
)
if batch:
log.info(f'Claimed {len(batch)} due automation(s)')
for automation in batch:
asyncio.create_task(execute_automation(app, automation))
except Exception:
log.exception('Automation worker error')
# Jitter to spread load across instances
await asyncio.sleep(
AUTOMATION_POLL_INTERVAL + random.uniform(0, 2)
)
##########################
# Execute
####################
def _build_request(app) -> Request:
"""Build a minimal ASGI Request for chat_completion.
Mirrors the mock-request pattern used in main.py lifespan
(model pre-fetch, tool server init) for consistency.
"""
scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.0"},
"method": "POST",
"path": "/api/v1/automations/internal",
"query_string": b"",
"headers": Headers({}).raw,
"client": ("127.0.0.1", 0),
"server": ("127.0.0.1", 80),
"scheme": "http",
"app": app,
}
request = Request(scope)
# Ensure request.state is initialized with required attributes
request.state.token = None
request.state.enable_api_keys = False
return request
def _resolve_model_tool_ids(app, model_id: str) -> list[str]:
"""Read model-attached tool_ids from model config.
The frontend does this in Chat.svelte (model.info.meta.toolIds).
The backend never auto-resolves them, so we must do it explicitly.
"""
models = getattr(app.state, "MODELS", {})
model = models.get(model_id, {})
tool_ids = model.get("info", {}).get("meta", {}).get("toolIds", [])
return list(tool_ids) if tool_ids else []
def _resolve_model_features(app, model_id: str) -> dict:
"""Read model default features from model config.
The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds
+ model.info.meta.capabilities). Enables features like web_search,
code_interpreter, image_generation when the model has them as defaults
AND the capability is enabled AND the admin has enabled the feature.
"""
models = getattr(app.state, "MODELS", {})
model = models.get(model_id, {})
meta = model.get("info", {}).get("meta", {})
default_feature_ids = meta.get("defaultFeatureIds", [])
if not default_feature_ids:
return {}
capabilities = meta.get("capabilities", {})
config = app.state.config
features = {}
feature_checks = {
"web_search": getattr(config, "ENABLE_WEB_SEARCH", False),
"image_generation": getattr(config, "ENABLE_IMAGE_GENERATION", False),
"code_interpreter": getattr(config, "ENABLE_CODE_INTERPRETER", False),
}
for feature_id in default_feature_ids:
if feature_id in feature_checks:
# Feature must be: in defaultFeatureIds + capability enabled + admin enabled
if capabilities.get(feature_id) and feature_checks[feature_id]:
features[feature_id] = True
return features
def _resolve_model_filter_ids(app, model_id: str) -> list[str]:
"""Read model default filter_ids from model config."""
models = getattr(app.state, "MODELS", {})
model = models.get(model_id, {})
filter_ids = model.get("info", {}).get("meta", {}).get("defaultFilterIds", [])
return list(filter_ids) if filter_ids else []
async def execute_automation(app, automation: AutomationModel) -> None:
"""Execute an automation through the full chat completion pipeline.
Creates a real chat, then calls chat_completion exactly like the frontend:
session_id + chat_id + message_id → async task → pipeline handles everything
(filters, model params, knowledge/RAG, tools, DB saves, webhooks).
"""
try:
user = Users.get_user_by_id(automation.user_id)
if not user:
_record_run(automation.id, "error", error="User not found")
return
prompt = prompt_template(automation.data["prompt"], user)
model_id = automation.data["model_id"]
# Generate proper UUIDs for messages (same as frontend)
user_msg_id = str(uuid4())
assistant_msg_id = str(uuid4())
# Create the chat with user message (same structure as frontend)
chat = Chats.insert_new_chat(
automation.user_id,
ChatForm(
chat={
"title": f"[Automation] {automation.name}",
"models": [model_id],
"history": {
"currentId": user_msg_id,
"messages": {
user_msg_id: {
"id": user_msg_id,
"parentId": None,
"role": "user",
"content": prompt,
"childrenIds": [assistant_msg_id],
"timestamp": int(time.time()),
"models": [model_id],
},
assistant_msg_id: {
"id": assistant_msg_id,
"parentId": user_msg_id,
"role": "assistant",
"content": "",
"model": model_id,
"childrenIds": [],
"timestamp": int(time.time()),
},
},
},
"messages": [
{"role": "user", "content": prompt},
],
"meta": {"automation_id": automation.id},
}
),
)
if not chat:
_record_run(automation.id, "error", error="Failed to create chat")
return
# Notify frontend to refresh chat list
from open_webui.socket.main import sio
await sio.emit(
"events",
{
"chat_id": chat.id,
"message_id": user_msg_id,
"data": {"type": "chat:list"},
},
room=f"user:{automation.user_id}",
)
# Resolve model defaults (frontend does this, backend doesn't)
tool_ids = _resolve_model_tool_ids(app, model_id)
features = _resolve_model_features(app, model_id)
filter_ids = _resolve_model_filter_ids(app, model_id)
# Build the same payload the frontend sends to /api/chat/completions
form_data = {
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"stream": True,
"chat_id": chat.id,
"id": assistant_msg_id,
"parent_id": user_msg_id,
"session_id": f"automation:{automation.id}",
"background_tasks": {},
}
if tool_ids:
form_data["tool_ids"] = tool_ids
if features:
form_data["features"] = features
if filter_ids:
form_data["filter_ids"] = filter_ids
# Call chat_completion — returns {'status': True, 'task_id': '...'}
# The background task handles everything: LLM, tools, DB, webhooks.
from open_webui.main import chat_completion as main_chat_completion
request = _build_request(app)
await main_chat_completion(request, form_data, user=user)
# Notify user
from open_webui.socket.main import sio
await sio.emit(
"automation:result",
{
"automation_id": automation.id,
"name": automation.name,
"chat_id": chat.id,
"status": "success",
},
room=f"user:{automation.user_id}",
)
_record_run(automation.id, "success", chat_id=chat.id)
except Exception as e:
log.exception(f"Automation {automation.id} failed")
_record_run(automation.id, "error", error=str(e)[:4000])
####################
# Internals
####################
def _record_run(
automation_id: str,
status: str,
chat_id: str = None,
error: str = None,
):
"""Insert a run record into automation_run."""
with get_db() as db:
AutomationRuns.insert(
automation_id, status, chat_id=chat_id, error=error, db=db
)
+17 -9
View File
@@ -2750,16 +2750,22 @@ async def process_chat_payload(request, form_data, user, metadata, model):
def get_event_emitter_and_caller(metadata):
event_emitter = None
event_caller = None
if (
'session_id' in metadata
and metadata['session_id']
and 'chat_id' in metadata
and metadata['chat_id']
and 'message_id' in metadata
and metadata['message_id']
):
# event_emitter only needs user_id + chat_id + message_id.
# It broadcasts to user:{user_id} room AND persists to DB,
# so it works for backend-initiated calls (automations, API).
if metadata.get('chat_id') and metadata.get('message_id'):
event_emitter = get_event_emitter(metadata)
# event_caller needs session_id — it calls back to a specific
# websocket session (used by direct tools, pyodide code interpreter).
if (
metadata.get('session_id')
and metadata.get('chat_id')
and metadata.get('message_id')
):
event_caller = get_event_call(metadata)
return event_emitter, event_caller
@@ -3206,7 +3212,9 @@ async def streaming_chat_response_handler(response, ctx):
]
# Standard streaming response handler
if event_emitter and event_caller:
# event_caller is optional — only needed for direct (client-side) tools
# and pyodide code interpreter. Server-side tools work without it.
if event_emitter:
task_id = str(uuid4()) # Create a unique task ID.
model_id = form_data.get('model', '')
+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>