feat: automation
This commit is contained in:
@@ -97,6 +97,7 @@ from open_webui.routers import (
|
|||||||
utils,
|
utils,
|
||||||
scim,
|
scim,
|
||||||
terminals,
|
terminals,
|
||||||
|
automations,
|
||||||
)
|
)
|
||||||
|
|
||||||
from open_webui.routers.retrieval import (
|
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_usage_pool_cleanup())
|
||||||
asyncio.create_task(periodic_session_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:
|
if app.state.config.ENABLE_BASE_MODELS_CACHE:
|
||||||
try:
|
try:
|
||||||
await get_all_models(
|
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(analytics.router, prefix='/api/v1/analytics', tags=['analytics'])
|
||||||
app.include_router(utils.router, prefix='/api/v1/utils', tags=['utils'])
|
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(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
|
# SCIM 2.0 API for identity management
|
||||||
if ENABLE_SCIM:
|
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")
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -2750,16 +2750,22 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||||||
def get_event_emitter_and_caller(metadata):
|
def get_event_emitter_and_caller(metadata):
|
||||||
event_emitter = None
|
event_emitter = None
|
||||||
event_caller = None
|
event_caller = None
|
||||||
if (
|
|
||||||
'session_id' in metadata
|
# event_emitter only needs user_id + chat_id + message_id.
|
||||||
and metadata['session_id']
|
# It broadcasts to user:{user_id} room AND persists to DB,
|
||||||
and 'chat_id' in metadata
|
# so it works for backend-initiated calls (automations, API).
|
||||||
and metadata['chat_id']
|
if metadata.get('chat_id') and metadata.get('message_id'):
|
||||||
and 'message_id' in metadata
|
|
||||||
and metadata['message_id']
|
|
||||||
):
|
|
||||||
event_emitter = get_event_emitter(metadata)
|
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)
|
event_caller = get_event_call(metadata)
|
||||||
|
|
||||||
return event_emitter, event_caller
|
return event_emitter, event_caller
|
||||||
|
|
||||||
|
|
||||||
@@ -3206,7 +3212,9 @@ async def streaming_chat_response_handler(response, ctx):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Standard streaming response handler
|
# 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.
|
task_id = str(uuid4()) # Create a unique task ID.
|
||||||
model_id = form_data.get('model', '')
|
model_id = form_data.get('model', '')
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -175,7 +175,8 @@
|
|||||||
bind:this={contentEl}
|
bind:this={contentEl}
|
||||||
class={contentClass}
|
class={contentClass}
|
||||||
transition:flyAndScale
|
transition:flyAndScale
|
||||||
on:click|stopPropagation
|
on:click={(e) => e.stopPropagation()}
|
||||||
|
on:pointerdown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<slot name="content" />
|
<slot name="content" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let handleOutsidePointerDown;
|
||||||
|
let handleModalFocusIn;
|
||||||
|
|
||||||
$: if (show && modalElement) {
|
$: if (show && modalElement) {
|
||||||
document.body.appendChild(modalElement);
|
document.body.appendChild(modalElement);
|
||||||
focusTrap = FocusTrap.createFocusTrap(modalElement, {
|
focusTrap = FocusTrap.createFocusTrap(modalElement, {
|
||||||
@@ -66,10 +69,34 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
focusTrap.activate();
|
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);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
} else if (modalElement) {
|
} 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);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
document.body.removeChild(modalElement);
|
document.body.removeChild(modalElement);
|
||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
|
|||||||
@@ -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: {
|
const chatActiveEventHandler = (event: {
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
message_id: string;
|
message_id: string;
|
||||||
@@ -553,6 +553,8 @@
|
|||||||
}
|
}
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
} else if (event.data?.type === 'chat:list') {
|
||||||
|
initChatList();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,40 @@
|
|||||||
<div class=" self-center truncate">{$i18n.t('Settings')}</div>
|
<div class=" self-center truncate">{$i18n.t('Settings')}</div>
|
||||||
</button>
|
</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
|
<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"
|
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"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, getContext } from 'svelte';
|
||||||
|
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { WEBUI_NAME, mobile, showSidebar } from '$lib/stores';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAutomations,
|
||||||
|
toggleAutomationById,
|
||||||
|
runAutomationById,
|
||||||
|
deleteAutomationById,
|
||||||
|
type AutomationResponse
|
||||||
|
} from '$lib/apis/automations';
|
||||||
|
|
||||||
|
import AutomationModal from '$lib/components/AutomationModal.svelte';
|
||||||
|
import AutomationMenu from '$lib/components/automations/AutomationMenu.svelte';
|
||||||
|
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
|
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
|
||||||
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||||
|
import Select from '$lib/components/common/Select.svelte';
|
||||||
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import Check from '$lib/components/icons/Check.svelte';
|
||||||
|
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
let automations: AutomationResponse[] = [];
|
||||||
|
|
||||||
|
let showEditor = false;
|
||||||
|
let editingAutomation: AutomationResponse | null = null;
|
||||||
|
|
||||||
|
let showDeleteConfirm = false;
|
||||||
|
let deleteTarget: AutomationResponse | null = null;
|
||||||
|
|
||||||
|
let query = '';
|
||||||
|
let statusFilter = 'all';
|
||||||
|
|
||||||
|
const getFilteredAutomations = (list, q, status) => {
|
||||||
|
let filtered = list;
|
||||||
|
if (status === 'active') {
|
||||||
|
filtered = filtered.filter((a) => a.is_active);
|
||||||
|
} else if (status === 'paused') {
|
||||||
|
filtered = filtered.filter((a) => !a.is_active);
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
filtered = filtered.filter((a) =>
|
||||||
|
a.name.toLowerCase().includes(lower) ||
|
||||||
|
a.data.prompt.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutomationList = async () => {
|
||||||
|
const res = await getAutomations(localStorage.token).catch((err) => {
|
||||||
|
toast.error(`${err}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (res) {
|
||||||
|
automations = res;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHandler = async (automation: AutomationResponse) => {
|
||||||
|
const res = await toggleAutomationById(localStorage.token, automation.id).catch((err) => {
|
||||||
|
toast.error(`${err}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (res) {
|
||||||
|
automations = automations.map((a) => (a.id === res.id ? res : a));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runNowHandler = async (automation: AutomationResponse) => {
|
||||||
|
const res = await runAutomationById(localStorage.token, automation.id).catch((err) => {
|
||||||
|
toast.error(`${err}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (res) {
|
||||||
|
toast.success($i18n.t('Automation triggered'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHandler = async (automation: AutomationResponse) => {
|
||||||
|
const res = await deleteAutomationById(localStorage.token, automation.id).catch((err) => {
|
||||||
|
toast.error(`${err}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (res) {
|
||||||
|
toast.success($i18n.t(`Deleted {{name}}`, { name: automation.name }));
|
||||||
|
automations = automations.filter((a) => a.id !== automation.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRRule = (rrule: string): string => {
|
||||||
|
const parts: Record<string, string> = {};
|
||||||
|
rrule
|
||||||
|
.replace('RRULE:', '')
|
||||||
|
.split(';')
|
||||||
|
.forEach((p) => {
|
||||||
|
const [k, v] = p.split('=');
|
||||||
|
if (k && v) parts[k] = v;
|
||||||
|
});
|
||||||
|
const freq = parts.FREQ || '';
|
||||||
|
const hour = parseInt(parts.BYHOUR || '0');
|
||||||
|
const min = (parts.BYMINUTE || '0').padStart(2, '0');
|
||||||
|
const iv = parseInt(parts.INTERVAL || '1');
|
||||||
|
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const h12 = hour % 12 || 12;
|
||||||
|
const time = `${h12}:${min} ${ampm}`;
|
||||||
|
|
||||||
|
if (freq === 'MINUTELY') return iv === 1 ? 'Every minute' : `Every ${iv} minutes`;
|
||||||
|
if (freq === 'HOURLY') return iv === 1 ? 'Hourly' : `Every ${iv} hours`;
|
||||||
|
if (freq === 'DAILY') return `Daily at ${time}`;
|
||||||
|
if (freq === 'WEEKLY') {
|
||||||
|
const days = parts.BYDAY || '';
|
||||||
|
return days ? `${days} at ${time}` : `Weekly at ${time}`;
|
||||||
|
}
|
||||||
|
if (freq === 'MONTHLY') return `Monthly on the ${parts.BYMONTHDAY || '1'}${ordinal(parts.BYMONTHDAY || '1')} at ${time}`;
|
||||||
|
return rrule;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ordinal = (n: string): string => {
|
||||||
|
const num = parseInt(n);
|
||||||
|
if (num % 10 === 1 && num !== 11) return 'st';
|
||||||
|
if (num % 10 === 2 && num !== 12) return 'nd';
|
||||||
|
if (num % 10 === 3 && num !== 13) return 'rd';
|
||||||
|
return 'th';
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativeTime = (ns: number): string => {
|
||||||
|
const diff = Date.now() - ns / 1_000_000;
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await getAutomationList();
|
||||||
|
loaded = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$i18n.t('Automations')} • {$WEBUI_NAME}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
bind:show={showDeleteConfirm}
|
||||||
|
title={$i18n.t('Delete automation?')}
|
||||||
|
on:confirm={() => {
|
||||||
|
if (deleteTarget) deleteHandler(deleteTarget);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-500 truncate">
|
||||||
|
{$i18n.t('This will delete')} <span class="font-medium">{deleteTarget?.name}</span>.
|
||||||
|
</div>
|
||||||
|
</DeleteConfirmDialog>
|
||||||
|
|
||||||
|
<AutomationModal
|
||||||
|
bind:show={showEditor}
|
||||||
|
automation={editingAutomation}
|
||||||
|
on:save={() => {
|
||||||
|
editingAutomation = null;
|
||||||
|
getAutomationList();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||||
|
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
|
: ''} max-w-full"
|
||||||
|
>
|
||||||
|
<div class="flex-1 max-h-full overflow-y-auto">
|
||||||
|
<div class="pb-1 px-3 md:px-[18px] pt-1.5">
|
||||||
|
{#if loaded}
|
||||||
|
<div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||||
|
{#if $mobile}
|
||||||
|
<Tooltip
|
||||||
|
content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
id="sidebar-toggle-button"
|
||||||
|
class="cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
||||||
|
on:click={() => {
|
||||||
|
showSidebar.set(!$showSidebar);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="self-center p-1.5">
|
||||||
|
<SidebarIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
<div>{$i18n.t('Automations')}</div>
|
||||||
|
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
|
||||||
|
{automations.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
class="px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
|
||||||
|
on:click={() => {
|
||||||
|
editingAutomation = null;
|
||||||
|
showEditor = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-3" strokeWidth="2.5" />
|
||||||
|
<div class="hidden md:block md:ml-1 text-xs">
|
||||||
|
{$i18n.t('New Automation')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
|
||||||
|
>
|
||||||
|
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||||
|
<div class="flex flex-1 items-center">
|
||||||
|
<div class="self-center ml-1 mr-3">
|
||||||
|
<Search className="size-3.5" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
|
bind:value={query}
|
||||||
|
aria-label={$i18n.t('Search Automations')}
|
||||||
|
placeholder={$i18n.t('Search Automations')}
|
||||||
|
maxlength="500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if query}
|
||||||
|
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||||
|
aria-label={$i18n.t('Clear search')}
|
||||||
|
on:click={() => {
|
||||||
|
query = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XMark className="size-3" strokeWidth="2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-3 flex w-full bg-transparent overflow-x-auto scrollbar-none -mx-1">
|
||||||
|
<div class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap">
|
||||||
|
<Select
|
||||||
|
bind:value={statusFilter}
|
||||||
|
items={[
|
||||||
|
{ value: 'all', label: $i18n.t('All') },
|
||||||
|
{ value: 'active', label: $i18n.t('Active') },
|
||||||
|
{ value: 'paused', label: $i18n.t('Paused') }
|
||||||
|
]}
|
||||||
|
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
|
||||||
|
>
|
||||||
|
<svelte:fragment slot="trigger" let:selectedLabel>
|
||||||
|
<span class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
|
||||||
|
{selectedLabel}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-3.5" strokeWidth="2.5" />
|
||||||
|
</svelte:fragment>
|
||||||
|
|
||||||
|
<svelte:fragment slot="item" let:item let:selected>
|
||||||
|
{item.label}
|
||||||
|
<div class="ml-auto {selected ? '' : 'invisible'}">
|
||||||
|
<Check />
|
||||||
|
</div>
|
||||||
|
</svelte:fragment>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if getFilteredAutomations(automations, query, statusFilter).length === 0}
|
||||||
|
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24">
|
||||||
|
<div class="max-w-md text-center">
|
||||||
|
<div class="text-3xl mb-3">⚡</div>
|
||||||
|
<div class="text-lg font-medium mb-1">
|
||||||
|
{query ? $i18n.t('No results found') : $i18n.t('No automations found')}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500 text-center text-xs">
|
||||||
|
{query
|
||||||
|
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
|
||||||
|
: $i18n.t('Create scheduled prompts that run automatically on a recurring basis.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="gap-2 grid my-2 px-3">
|
||||||
|
{#each getFilteredAutomations(automations, query, statusFilter) as automation (automation.id)}
|
||||||
|
<div
|
||||||
|
class="flex space-x-4 text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<div
|
||||||
|
class="flex-1 cursor-pointer"
|
||||||
|
on:click={() => {
|
||||||
|
editingAutomation = automation;
|
||||||
|
showEditor = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="line-clamp-1 text-sm">{automation.name}</div>
|
||||||
|
<div class="text-xs text-gray-500 line-clamp-1">
|
||||||
|
{formatRRule(automation.data.rrule)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-0.5 self-center">
|
||||||
|
<AutomationMenu
|
||||||
|
editHandler={() => {
|
||||||
|
editingAutomation = automation;
|
||||||
|
showEditor = true;
|
||||||
|
}}
|
||||||
|
runHandler={() => {
|
||||||
|
runNowHandler(automation);
|
||||||
|
}}
|
||||||
|
deleteHandler={() => {
|
||||||
|
deleteTarget = automation;
|
||||||
|
showDeleteConfirm = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisHorizontal className="size-5" />
|
||||||
|
</button>
|
||||||
|
</AutomationMenu>
|
||||||
|
|
||||||
|
<button on:click={(e) => { e.stopPropagation(); e.preventDefault(); }}>
|
||||||
|
<Tooltip
|
||||||
|
content={automation.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
bind:state={automation.is_active}
|
||||||
|
on:change={() => {
|
||||||
|
toggleHandler(automation);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user