Merge pull request #23303 from open-webui/automations
feat: automations
This commit is contained in:
@@ -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:
|
||||
@@ -1877,6 +1882,10 @@ async def chat_completion(
|
||||
generate_chat_completions = chat_completion
|
||||
generate_chat_completion = chat_completion
|
||||
|
||||
# Expose as app.state so internal callers (e.g. automations) can
|
||||
# use the full pipeline without importing from main.py (avoids circular deps).
|
||||
app.state.CHAT_COMPLETION_HANDLER = chat_completion
|
||||
|
||||
|
||||
##################################
|
||||
#
|
||||
|
||||
@@ -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,376 @@
|
||||
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, or_, func, cast, String
|
||||
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 AutomationTerminalConfig(BaseModel):
|
||||
server_id: str
|
||||
cwd: Optional[str] = None
|
||||
|
||||
|
||||
class AutomationData(BaseModel):
|
||||
prompt: str
|
||||
model_id: str
|
||||
rrule: str
|
||||
terminal: Optional[AutomationTerminalConfig] = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class AutomationListResponse(BaseModel):
|
||||
items: list[AutomationModel]
|
||||
total: int
|
||||
|
||||
|
||||
####################
|
||||
# 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 search_automations(
|
||||
self,
|
||||
user_id: str,
|
||||
query: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[Session] = None,
|
||||
) -> 'AutomationListResponse':
|
||||
with get_db_context(db) as db:
|
||||
q = db.query(Automation).filter_by(user_id=user_id)
|
||||
|
||||
if query:
|
||||
search = f'%{query}%'
|
||||
# Search in name and prompt inside JSON data
|
||||
q = q.filter(
|
||||
or_(
|
||||
Automation.name.ilike(search),
|
||||
cast(Automation.data, String).ilike(search),
|
||||
)
|
||||
)
|
||||
|
||||
if status == 'active':
|
||||
q = q.filter(Automation.is_active == True)
|
||||
elif status == 'paused':
|
||||
q = q.filter(Automation.is_active == False)
|
||||
|
||||
q = q.order_by(Automation.created_at.desc())
|
||||
|
||||
total = q.count()
|
||||
|
||||
if skip:
|
||||
q = q.offset(skip)
|
||||
if limit:
|
||||
q = q.limit(limit)
|
||||
|
||||
rows = q.all()
|
||||
return AutomationListResponse(
|
||||
items=[AutomationModel.model_validate(r) for r in rows],
|
||||
total=total,
|
||||
)
|
||||
|
||||
def get_all(self, db: Optional[Session] = None) -> list[AutomationModel]:
|
||||
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,293 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from typing import Optional
|
||||
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,
|
||||
AutomationListResponse,
|
||||
)
|
||||
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()
|
||||
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
############################
|
||||
# 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]
|
||||
|
||||
|
||||
############################
|
||||
# GetAutomationItems (paginated)
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/list')
|
||||
async def get_automation_items(
|
||||
request: Request,
|
||||
query: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
limit = PAGE_ITEM_COUNT
|
||||
page = max(1, page)
|
||||
skip = (page - 1) * limit
|
||||
|
||||
result = Automations.search_automations(
|
||||
user_id=user.id,
|
||||
query=query,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return {
|
||||
'items': [
|
||||
enrich_automation(item, db, tz=user.timezone)
|
||||
for item in result.items
|
||||
],
|
||||
'total': result.total,
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# CreateNewAutomation
|
||||
############################
|
||||
|
||||
|
||||
@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),
|
||||
)
|
||||
|
||||
# Validate terminal server exists if linked
|
||||
if form_data.data.terminal and form_data.data.terminal.server_id:
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
if not any(c.get('id') == form_data.data.terminal.server_id for c in connections):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Terminal server not found',
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
# Validate terminal server exists if linked
|
||||
if form_data.data.terminal and form_data.data.terminal.server_id:
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
if not any(c.get('id') == form_data.data.terminal.server_id for c in connections):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Terminal server not found',
|
||||
)
|
||||
|
||||
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,425 @@
|
||||
"""
|
||||
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 _parse_rule(s: str):
|
||||
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
|
||||
|
||||
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
|
||||
so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10).
|
||||
"""
|
||||
raw = s.replace('RRULE:', '')
|
||||
parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p)
|
||||
freq = parts.get('FREQ', '')
|
||||
|
||||
if freq in ('MINUTELY', 'HOURLY'):
|
||||
epoch = datetime(2000, 1, 1, 0, 0, 0)
|
||||
return rrulestr(s, dtstart=epoch, ignoretz=True)
|
||||
return rrulestr(s, ignoretz=True)
|
||||
|
||||
|
||||
def validate_rrule(s: str) -> None:
|
||||
"""Raise ValueError if the RRULE is malformed or exhausted."""
|
||||
try:
|
||||
rule = _parse_rule(s)
|
||||
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 = _parse_rule(s).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 = _parse_rule(s)
|
||||
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 = {}
|
||||
|
||||
# code_interpreter is excluded: it requires the frontend event emitter
|
||||
# and does not work in headless backend execution.
|
||||
feature_checks = {
|
||||
"web_search": getattr(config, "ENABLE_WEB_SEARCH", False),
|
||||
"image_generation": getattr(config, "ENABLE_IMAGE_GENERATION", 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 _set_terminal_cwd(
|
||||
app, server_id: str, user, cwd: str, chat_id: str
|
||||
) -> None:
|
||||
"""Set the working directory on a terminal server via the proxy.
|
||||
|
||||
Routes through the open-webui terminal proxy endpoint so that
|
||||
auth headers, orchestrator policy routing, and X-User-Id are
|
||||
handled correctly — same path the frontend uses.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
connections = getattr(
|
||||
getattr(app, 'state', None), 'config', None
|
||||
)
|
||||
if connections is None:
|
||||
return
|
||||
connections = getattr(connections, 'TERMINAL_SERVER_CONNECTIONS', None) or []
|
||||
connection = next((c for c in connections if c.get('id') == server_id), None)
|
||||
if connection is None:
|
||||
log.warning(f'Terminal server {server_id} not found for CWD set')
|
||||
return
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
if not base_url:
|
||||
return
|
||||
|
||||
# Build target URL — route through orchestrator policy if configured
|
||||
policy_id = connection.get('policy_id')
|
||||
if connection.get('server_type') == 'orchestrator' and policy_id:
|
||||
target_url = f'{base_url}/p/{policy_id}/files/cwd'
|
||||
else:
|
||||
target_url = f'{base_url}/files/cwd'
|
||||
|
||||
headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
|
||||
if chat_id:
|
||||
headers['X-Session-Id'] = chat_id
|
||||
|
||||
auth_type = connection.get('auth_type', 'bearer')
|
||||
if auth_type == 'bearer':
|
||||
headers['Authorization'] = f'Bearer {connection.get("key", "")}'
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=10)
|
||||
) as session:
|
||||
async with session.post(
|
||||
target_url,
|
||||
json={'path': cwd},
|
||||
headers=headers,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
log.warning(
|
||||
f'Failed to set terminal CWD to {cwd}: '
|
||||
f'HTTP {resp.status} — {body[:200]}'
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to set terminal CWD: {e}')
|
||||
|
||||
|
||||
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"]
|
||||
terminal_config = automation.data.get("terminal")
|
||||
|
||||
# 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": automation.name,
|
||||
"models": [model_id],
|
||||
"history": {
|
||||
"currentId": assistant_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": "",
|
||||
"done": False,
|
||||
"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)
|
||||
|
||||
# If a terminal is linked, set the CWD before building the payload
|
||||
terminal_id = None
|
||||
if terminal_config and terminal_config.get("server_id"):
|
||||
terminal_id = terminal_config["server_id"]
|
||||
cwd = terminal_config.get("cwd")
|
||||
if cwd:
|
||||
await _set_terminal_cwd(app, terminal_id, user, cwd, chat.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
|
||||
if terminal_id:
|
||||
form_data["terminal_id"] = terminal_id
|
||||
|
||||
# Call the full chat completion pipeline (same as POST /api/chat/completions).
|
||||
# The handler reference is stored on app.state to avoid circular imports.
|
||||
request = _build_request(app)
|
||||
await app.state.CHAT_COMPLETION_HANDLER(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):
|
||||
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', '')
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export type AutomationTerminalConfig = {
|
||||
server_id: string;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type AutomationData = {
|
||||
prompt: string;
|
||||
model_id: string;
|
||||
rrule: string;
|
||||
terminal?: AutomationTerminalConfig;
|
||||
};
|
||||
|
||||
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 getAutomationItems = async (
|
||||
token: string,
|
||||
query: string | null,
|
||||
status: string | null,
|
||||
page: number
|
||||
): Promise<{ items: AutomationResponse[]; total: number }> => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (query) {
|
||||
searchParams.append('query', query);
|
||||
}
|
||||
if (status && status !== 'all') {
|
||||
searchParams.append('status', status);
|
||||
}
|
||||
if (page) {
|
||||
searchParams.append('page', page.toString());
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/list?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const createAutomation = async (token: string, form: AutomationForm) => {
|
||||
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,666 @@
|
||||
<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 Cloud from '$lib/components/icons/Cloud.svelte';
|
||||
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
import {
|
||||
createAutomation,
|
||||
updateAutomationById,
|
||||
type AutomationForm,
|
||||
type AutomationResponse
|
||||
} from '$lib/apis/automations';
|
||||
import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal/index';
|
||||
|
||||
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 onceDate = '';
|
||||
let onceTime = '09:00';
|
||||
|
||||
let loading = false;
|
||||
let showScheduleDropdown = false;
|
||||
let showModelDropdown = false;
|
||||
let showTerminalDropdown = false;
|
||||
let modelSearch = '';
|
||||
let customRrule = '';
|
||||
|
||||
// Terminal state
|
||||
let terminalServers: TerminalServer[] = [];
|
||||
let terminalServerId = '';
|
||||
let terminalCwd = '';
|
||||
|
||||
$: terminalLabel = terminalServerId
|
||||
? terminalServers.find((s) => s.id === terminalServerId)?.name || 'Terminal'
|
||||
: $i18n.t('Terminal');
|
||||
|
||||
$: 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: 'ONCE', label: 'Once' },
|
||||
{ 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 => {
|
||||
if (lastVisualFrequency === 'ONCE') {
|
||||
const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00';
|
||||
return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`;
|
||||
}
|
||||
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 === 'ONCE' && !onceDate) {
|
||||
const soon = new Date(Date.now() + 5 * 60_000);
|
||||
onceDate = soon.toISOString().split('T')[0];
|
||||
onceTime = `${String(soon.getHours()).padStart(2, '0')}:${String(soon.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (frequency === 'CUSTOM' && prevFrequency !== 'CUSTOM') {
|
||||
customRrule = buildVisualRrule();
|
||||
}
|
||||
prevFrequency = frequency;
|
||||
}
|
||||
|
||||
const buildRrule = (): string => {
|
||||
if (frequency === 'CUSTOM') return customRrule;
|
||||
if (frequency === 'ONCE') {
|
||||
const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00';
|
||||
return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`;
|
||||
}
|
||||
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) => {
|
||||
// Detect ONCE (COUNT=1 with DTSTART)
|
||||
if (s.includes('COUNT=1')) {
|
||||
frequency = 'ONCE';
|
||||
const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
|
||||
if (match) {
|
||||
onceDate = `${match[1]}-${match[2]}-${match[3]}`;
|
||||
onceTime = `${match[4]}:${match[5]}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
if (freq === 'ONCE') return 'Once';
|
||||
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;
|
||||
}
|
||||
if (frequency === 'ONCE') {
|
||||
const scheduled = new Date(`${onceDate}T${onceTime}`);
|
||||
if (scheduled <= new Date()) {
|
||||
toast.error($i18n.t('Scheduled time must be in the future'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const form: AutomationForm = {
|
||||
name: name.trim(),
|
||||
data: {
|
||||
prompt: prompt.trim(),
|
||||
model_id: model_id.trim(),
|
||||
rrule: buildRrule(),
|
||||
...(terminalServerId
|
||||
? {
|
||||
terminal: {
|
||||
server_id: terminalServerId,
|
||||
...(terminalCwd.trim() ? { cwd: terminalCwd.trim() } : {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
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 = async () => {
|
||||
// Load terminal servers
|
||||
try {
|
||||
terminalServers = await getTerminalServers(localStorage.token);
|
||||
} catch {
|
||||
terminalServers = [];
|
||||
}
|
||||
|
||||
if (automation) {
|
||||
name = automation.name;
|
||||
prompt = automation.data.prompt;
|
||||
model_id = automation.data.model_id;
|
||||
is_active = automation.is_active;
|
||||
parseRrule(automation.data.rrule);
|
||||
terminalServerId = automation.data.terminal?.server_id || '';
|
||||
terminalCwd = automation.data.terminal?.cwd || '';
|
||||
} else {
|
||||
name = '';
|
||||
prompt = '';
|
||||
model_id = '';
|
||||
is_active = true;
|
||||
frequency = 'DAILY';
|
||||
interval = 1;
|
||||
onceDate = '';
|
||||
onceTime = '09:00';
|
||||
hour = 9;
|
||||
minute = 0;
|
||||
selectedDays = [];
|
||||
monthDay = 1;
|
||||
terminalServerId = '';
|
||||
terminalCwd = '';
|
||||
}
|
||||
showScheduleDropdown = false;
|
||||
showTerminalDropdown = 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-48 p-1"
|
||||
>
|
||||
<div class="px-2 text-xs text-gray-500 py-1">
|
||||
{$i18n.t('Schedule')}
|
||||
</div>
|
||||
|
||||
<div class="px-1.5 mt-0.5 mb-2">
|
||||
<select
|
||||
class="w-full bg-transparent rounded-xl text-xs py-1.5 px-1.5 outline-hidden"
|
||||
bind:value={frequency}
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#each FREQUENCIES as f}
|
||||
<option value={f.key}>{f.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</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 if frequency !== 'HOURLY'}
|
||||
<div class="flex gap-2 flex-wrap items-center px-3 pb-2 text-xs">
|
||||
{#if frequency === 'ONCE'}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
bind:value={onceDate}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="time"
|
||||
bind:value={onceTime}
|
||||
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</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-transparent text-center outline-hidden text-xs 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-8 bg-transparent text-center outline-hidden text-xs"
|
||||
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>
|
||||
|
||||
<!-- Terminal dropdown -->
|
||||
{#if terminalServers.length > 0}
|
||||
<Dropdown bind:show={showTerminalDropdown} 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
|
||||
{terminalServerId
|
||||
? 'text-black dark:text-gray-100'
|
||||
: 'text-gray-600 dark:text-gray-400'}
|
||||
hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Cloud className="size-3.5 shrink-0" strokeWidth="2" />
|
||||
<span class="whitespace-nowrap max-w-32 truncate">{terminalLabel}</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 min-w-56 max-w-56 p-1"
|
||||
>
|
||||
<div class="px-2 text-xs text-gray-500 py-1">
|
||||
{$i18n.t('Terminal')}
|
||||
</div>
|
||||
|
||||
{#each terminalServers as server (server.id)}
|
||||
<button
|
||||
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl {terminalServerId ===
|
||||
server.id
|
||||
? 'bg-gray-50 dark:bg-gray-800/50'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'}"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (terminalServerId === server.id) {
|
||||
terminalServerId = '';
|
||||
terminalCwd = '';
|
||||
} else {
|
||||
terminalServerId = server.id;
|
||||
}
|
||||
showTerminalDropdown = false;
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-1 gap-2 items-center truncate">
|
||||
<Cloud className="size-4 shrink-0" strokeWidth="2" />
|
||||
<span class="truncate">{server.name || server.id}</span>
|
||||
</div>
|
||||
{#if terminalServerId === server.id}
|
||||
<div class="shrink-0 text-emerald-600 dark:text-emerald-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if terminalServerId}
|
||||
<div class="border-t border-gray-100 dark:border-gray-800 mt-1 pt-1">
|
||||
<div class="px-2.5 py-1 text-xs text-gray-500">
|
||||
{$i18n.t('Working Directory')}
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={terminalCwd}
|
||||
placeholder="/home/user/project"
|
||||
class="w-full bg-transparent outline-hidden text-xs py-1.5 placeholder:text-gray-400 dark:placeholder:text-gray-600"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
</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}
|
||||
class={contentClass}
|
||||
transition:flyAndScale
|
||||
on:click|stopPropagation
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
on:pointerdown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, getContext } from 'svelte';
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { WEBUI_NAME, mobile, showSidebar } from '$lib/stores';
|
||||
|
||||
import {
|
||||
getAutomationItems,
|
||||
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 Pagination from '$lib/components/common/Pagination.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[] | null = null;
|
||||
let total: number | null = null;
|
||||
let loading = false;
|
||||
|
||||
let showEditor = false;
|
||||
let editingAutomation: AutomationResponse | null = null;
|
||||
|
||||
let showDeleteConfirm = false;
|
||||
let deleteTarget: AutomationResponse | null = null;
|
||||
|
||||
let query = '';
|
||||
let statusFilter = 'all';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let page = 1;
|
||||
|
||||
// Debounce only query changes
|
||||
$: if (query !== undefined) {
|
||||
loading = true;
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
page = 1;
|
||||
getAutomationList();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Immediate response to page/filter changes
|
||||
$: if (page && statusFilter !== undefined) {
|
||||
getAutomationList();
|
||||
}
|
||||
|
||||
const getAutomationList = async () => {
|
||||
if (!loaded) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const res = await getAutomationItems(localStorage.token, query, statusFilter, page).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (res) {
|
||||
automations = res.items;
|
||||
total = res.total;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
page = 1;
|
||||
getAutomationList();
|
||||
};
|
||||
|
||||
const formatRRule = (rrule: string): string => {
|
||||
// Detect one-time schedule (ONCE)
|
||||
if (rrule.includes('COUNT=1')) {
|
||||
const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
|
||||
if (match) {
|
||||
const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`);
|
||||
return `Once · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`;
|
||||
}
|
||||
return 'Once';
|
||||
}
|
||||
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';
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
loaded = true;
|
||||
|
||||
return () => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
</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">
|
||||
{#if loaded}
|
||||
<div class="pb-1 px-3 md:px-[18px] pt-2">
|
||||
<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">
|
||||
{total ?? ''}
|
||||
</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') }
|
||||
]}
|
||||
onChange={() => {
|
||||
page = 1;
|
||||
}}
|
||||
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 automations === null || loading}
|
||||
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{:else if (automations ?? []).length === 0}
|
||||
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24">
|
||||
<div class="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 automations 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 total > 30}
|
||||
<div class="flex justify-center mt-4 mb-2">
|
||||
<Pagination bind:page count={total} perPage={30} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user