diff --git a/src/khoj/database/admin.py b/src/khoj/database/admin.py index 2fc5a1cd..0b6e42dd 100644 --- a/src/khoj/database/admin.py +++ b/src/khoj/database/admin.py @@ -1,9 +1,13 @@ import csv import json +from apscheduler.job import Job from django.contrib import admin from django.contrib.auth.admin import UserAdmin from django.http import HttpResponse +from django_apscheduler.admin import DjangoJobAdmin +from django_apscheduler.jobstores import DjangoJobStore +from django_apscheduler.models import DjangoJob from khoj.database.models import ( Agent, @@ -25,6 +29,35 @@ from khoj.database.models import ( ) from khoj.utils.helpers import ImageIntentType +admin.site.unregister(DjangoJob) + + +class KhojDjangoJobAdmin(DjangoJobAdmin): + list_display = ( + "id", + "next_run_time", + "job_info", + ) + search_fields = ("id", "next_run_time") + ordering = ("-next_run_time",) + job_store = DjangoJobStore() + + def job_info(self, obj): + job: Job = self.job_store.lookup_job(obj.id) + return f"{job.func_ref} {job.args} {job.kwargs}" if job else "None" + + job_info.short_description = "Job Info" # type: ignore + + def get_search_results(self, request, queryset, search_term): + queryset, use_distinct = super().get_search_results(request, queryset, search_term) + if search_term: + jobs = [job.id for job in self.job_store.get_all_jobs() if search_term in str(job)] + queryset |= self.model.objects.filter(id__in=jobs) + return queryset, use_distinct + + +admin.site.register(DjangoJob, KhojDjangoJobAdmin) + class KhojUserAdmin(UserAdmin): list_display = ( diff --git a/src/khoj/interface/web/agents.html b/src/khoj/interface/web/agents.html index 21a5cda2..36bb4caf 100644 --- a/src/khoj/interface/web/agents.html +++ b/src/khoj/interface/web/agents.html @@ -193,9 +193,55 @@ } } + .loader { + width: 48px; + height: 48px; + border-radius: 50%; + display: inline-block; + border-top: 4px solid var(--primary-color); + border-right: 4px solid transparent; + box-sizing: border-box; + animation: rotation 1s linear infinite; + } + .loader::after { + content: ''; + box-sizing: border-box; + position: absolute; + left: 0; + top: 0; + width: 48px; + height: 48px; + border-radius: 50%; + border-left: 4px solid var(--summer-sun); + border-bottom: 4px solid transparent; + animation: rotation 0.5s linear infinite reverse; + } + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + {% endblock %} diff --git a/src/khoj/routers/api.py b/src/khoj/routers/api.py index efda9841..4cdb53e4 100644 --- a/src/khoj/routers/api.py +++ b/src/khoj/routers/api.py @@ -3,6 +3,7 @@ import json import logging import math import os +import threading import time import uuid from typing import Any, Callable, List, Optional, Union @@ -36,6 +37,7 @@ from khoj.routers.helpers import ( ConversationCommandRateLimiter, acreate_title_from_query, schedule_automation, + scheduled_chat, update_telemetry_state, ) from khoj.search_filter.date_filter import DateFilter @@ -452,12 +454,19 @@ async def post_automation( crontime = " ".join(crontime.split(" ")[:5]) # Convert crontime to standard unix crontime crontime = crontime.replace("?", "*") + if crontime == "* * * * *": + return Response(content="Invalid crontime. Please create a more specific schedule.", status_code=400) subject = await acreate_title_from_query(q) + # Create new Conversation Session associated with this new task + conversation = await ConversationAdapters.acreate_conversation_session(user, request.user.client_app) + + calling_url = request.url.replace(query=f"{request.url.query}&conversation_id={conversation.id}") + # Schedule automation with query_to_run, timezone, subject directly provided by user try: # Use the query to run as the scheduling request if the scheduling request is unset - automation = await schedule_automation(query_to_run, subject, crontime, timezone, q, user, request.url) + automation = await schedule_automation(query_to_run, subject, crontime, timezone, q, user, calling_url) except Exception as e: logger.error(f"Error creating automation {q} for {user.email}: {e}", exc_info=True) return Response( @@ -473,6 +482,31 @@ async def post_automation( return Response(content=json.dumps(automation_info), media_type="application/json", status_code=200) +@api.post("/trigger/automation", response_class=Response) +@requires(["authenticated"]) +def trigger_manual_job( + request: Request, + automation_id: str, +): + user: KhojUser = request.user.object + + # Check, get automation to edit + try: + automation: Job = AutomationAdapters.get_automation(user, automation_id) + except ValueError as e: + logger.error(f"Error triggering automation {automation_id} for {user.email}: {e}", exc_info=True) + return Response(content="Invalid automation", status_code=403) + + # Trigger the job without waiting for the result. + scheduled_chat_func = automation.func + + # Run the function in a separate thread + thread = threading.Thread(target=scheduled_chat_func, args=automation.args, kwargs=automation.kwargs) + thread.start() + + return Response(content="Automation triggered", status_code=200) + + @api.put("/automation", response_class=Response) @requires(["authenticated"]) def edit_job( diff --git a/src/khoj/routers/helpers.py b/src/khoj/routers/helpers.py index 968eb040..27bff722 100644 --- a/src/khoj/routers/helpers.py +++ b/src/khoj/routers/helpers.py @@ -956,6 +956,8 @@ def scheduled_chat( async def create_automation(q: str, timezone: str, user: KhojUser, calling_url: URL, meta_log: dict = {}): crontime, query_to_run, subject = await schedule_query(q, meta_log) + if crontime == "* * * * *": + raise HTTPException(status_code=400, detail="Cannot run jobs constantly. Please provide a valid crontime.") job = await schedule_automation(query_to_run, subject, crontime, timezone, q, user, calling_url) return job, crontime, query_to_run, subject @@ -970,6 +972,8 @@ async def schedule_automation( calling_url: URL, ): user_timezone = pytz.timezone(timezone) + if crontime == "* * * * *": + raise HTTPException(status_code=400, detail="Cannot run jobs constantly. Please provide a valid crontime.") trigger = CronTrigger.from_crontab(crontime, user_timezone) trigger.jitter = 60 # Generate id and metadata used by task scheduler and process locks for the task runs diff --git a/src/khoj/routers/web_client.py b/src/khoj/routers/web_client.py index 8c3fcd2a..ec3606fc 100644 --- a/src/khoj/routers/web_client.py +++ b/src/khoj/routers/web_client.py @@ -11,7 +11,6 @@ from starlette.authentication import has_required_scope, requires from khoj.database import adapters from khoj.database.adapters import ( AgentAdapters, - AutomationAdapters, ConversationAdapters, EntryAdapters, PublicConversationAdapters,