Add a schedule picker and automations preview func (#747)

* Update suggested automations
* add a schedule picker when creating an automation
* Create a new conversation in flow of the automation scheduling in order to send a preview and deliver more consistent results
* Start adding in scaffolding to manually trigger a test job for an automation
* Add support for manually triggering automations for testing
* Schedule automation asynchronously
* Update styling of the preview button
* Improve admin lookup experience and prevent jobs from being scheduled to run every minute of everyday
* Ignore mypy issues on job info short description
This commit is contained in:
sabaimran 2024-05-24 09:12:47 -05:00 committed by GitHub
parent ac3e5089a2
commit cbbbe2da9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 496 additions and 48 deletions

View file

@ -1,9 +1,13 @@
import csv import csv
import json import json
from apscheduler.job import Job
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.http import HttpResponse 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 ( from khoj.database.models import (
Agent, Agent,
@ -25,6 +29,35 @@ from khoj.database.models import (
) )
from khoj.utils.helpers import ImageIntentType 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): class KhojUserAdmin(UserAdmin):
list_display = ( list_display = (

View file

@ -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);
}
}
</style> </style>
<script> <script>
async function openChat(agentSlug) { async function openChat(agentSlug) {
// Create a loading animation
let loading = document.createElement("div");
loading.innerHTML = '<div>Booting your agent...</div><span class="loader"></span>';
loading.style.position = "fixed";
loading.style.top = "0";
loading.style.right = "0";
loading.style.bottom = "0";
loading.style.left = "0";
loading.style.display = "flex";
loading.style.justifyContent = "center";
loading.style.alignItems = "center";
loading.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; // Semi-transparent black
document.body.appendChild(loading);
let response = await fetch(`/api/chat/sessions?agent_slug=${agentSlug}`, { method: "POST" }); let response = await fetch(`/api/chat/sessions?agent_slug=${agentSlug}`, { method: "POST" });
let data = await response.json(); let data = await response.json();
if (response.status == 200) { if (response.status == 200) {

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M704 288h-281.6l177.6-202.88a32 32 0 0 0-48.32-42.24l-224 256a30.08 30.08 0 0 0-2.24 3.84 32 32 0 0 0-2.88 4.16v1.92a32 32 0 0 0 0 5.12A32 32 0 0 0 320 320a32 32 0 0 0 0 4.8 32 32 0 0 0 0 5.12v1.92a32 32 0 0 0 2.88 4.16 30.08 30.08 0 0 0 2.24 3.84l224 256a32 32 0 1 0 48.32-42.24L422.4 352H704a224 224 0 0 1 224 224v128a224 224 0 0 1-224 224H320a232 232 0 0 1-28.16-1.6 32 32 0 0 0-35.84 27.84 32 32 0 0 0 27.84 35.52A295.04 295.04 0 0 0 320 992h384a288 288 0 0 0 288-288v-128a288 288 0 0 0-288-288zM103.04 760a32 32 0 0 0-62.08 16A289.92 289.92 0 0 0 140.16 928a32 32 0 0 0 40-49.92 225.6 225.6 0 0 1-77.12-118.08zM64 672a32 32 0 0 0 22.72-9.28 37.12 37.12 0 0 0 6.72-10.56A32 32 0 0 0 96 640a33.6 33.6 0 0 0-9.28-22.72 32 32 0 0 0-10.56-6.72 32 32 0 0 0-34.88 6.72A32 32 0 0 0 32 640a32 32 0 0 0 2.56 12.16 37.12 37.12 0 0 0 6.72 10.56A32 32 0 0 0 64 672z" fill="#231815" /></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.2799 6.40005L11.7399 15.94C10.7899 16.89 7.96987 17.33 7.33987 16.7C6.70987 16.07 7.13987 13.25 8.08987 12.3L17.6399 2.75002C17.8754 2.49308 18.1605 2.28654 18.4781 2.14284C18.7956 1.99914 19.139 1.92124 19.4875 1.9139C19.8359 1.90657 20.1823 1.96991 20.5056 2.10012C20.8289 2.23033 21.1225 2.42473 21.3686 2.67153C21.6147 2.91833 21.8083 3.21243 21.9376 3.53609C22.0669 3.85976 22.1294 4.20626 22.1211 4.55471C22.1128 4.90316 22.0339 5.24635 21.8894 5.5635C21.7448 5.88065 21.5375 6.16524 21.2799 6.40005V6.40005Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 4H6C4.93913 4 3.92178 4.42142 3.17163 5.17157C2.42149 5.92172 2 6.93913 2 8V18C2 19.0609 2.42149 20.0783 3.17163 20.8284C3.92178 21.5786 4.93913 22 6 22H17C19.21 22 20 20.2 20 18V13" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -62,6 +62,15 @@
color: var(--primary-color); color: var(--primary-color);
} }
img.automation-edit-cancel-icon,
img.automation-edit-icon {
width: 24px;
height: 24px;
object-fit: cover;
cursor: pointer;
margin: auto;
}
textarea.fake-input, textarea.fake-input,
input.fake-input { input.fake-input {
height: auto; height: auto;
@ -108,11 +117,17 @@
} }
button.save-automation-button, button.save-automation-button,
button.cancel-edit-automation-button,
button.send-preview-automation-button,
button.delete-automation-button { button.delete-automation-button {
padding: 8px; padding: 8px;
margin-bottom: 0; margin-bottom: 0;
} }
button.send-preview-automation-button {
border-color: var(--summer-sun);
}
button.save-automation-button:hover { button.save-automation-button:hover {
background-color: var(--primary-hover); background-color: var(--primary-hover);
} }
@ -122,10 +137,16 @@
box-shadow: 0 4px 6px 0 hsla(0, 0%, 0%, 0.2); box-shadow: 0 4px 6px 0 hsla(0, 0%, 0%, 0.2);
margin-bottom: 20px; margin-bottom: 20px;
transition: box-shadow 0.3s ease, transform 0.3s ease; transition: box-shadow 0.3s ease, transform 0.3s ease;
position: absolute;
top: 50%;
left: 50%;
width: auto;
transform: translate(-50%, -50%);
z-index: 2;
height: auto;
} }
div.automation:hover, div.automation:not(.new-automation):hover {
div.new-automation:hover {
box-shadow: 0 10px 15px 0 hsla(0, 0%, 0%, 0.1); box-shadow: 0 10px 15px 0 hsla(0, 0%, 0%, 0.1);
transform: translateY(-5px); transform: translateY(-5px);
} }
@ -181,6 +202,16 @@
height: 24px; height: 24px;
} }
div.subject-wrapper {
display: grid;
grid-template-columns: 1fr auto;
grid-gap: 8px;
}
div.subject-wrapper p {
margin: 0;
}
@keyframes confirmation { @keyframes confirmation {
0% { background-color: normal; transform: scale(1); } 0% { background-color: normal; transform: scale(1); }
50% { background-color: var(--primary); transform: scale(1.1); } 50% { background-color: var(--primary); transform: scale(1.1); }
@ -199,6 +230,10 @@
div.automation-buttons { div.automation-buttons {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
div.new-automation {
width: 100%;
height: auto;
}
} }
</style> </style>
@ -235,28 +270,88 @@
queryEl.value = automation.query_to_run; queryEl.value = automation.query_to_run;
} }
function onClickAutomationCard(automationId) { function onClickEditAutomationCard(automationId) {
const automationIDElements = document.querySelectorAll(`.${automationId}`); const automationIDElements = document.querySelectorAll(`.${automationId}`);
automationIDElements.forEach(el => { automationIDElements.forEach(el => {
// Only toggle the first time the button is clicked if (el.classList.contains("automation-edit-icon")) {
el.classList.remove("hide-details"); el.classList.remove("automation-edit-icon");
el.classList.remove("fake-input"); el.classList.add("automation-edit-cancel-icon");
el.src = "/static/assets/icons/cancel.svg";
el.onclick = function(event) { clickCancelEdit(event, automationId); };
}
if (el.classList.contains("hide-details")) {
el.classList.add("hide-details-placeholder");
el.classList.remove("hide-details");
}
if (el.classList.contains("fake-input")) {
el.classList.add("fake-input-placeholder");
el.classList.remove("fake-input");
}
}); });
} }
function sendAPreviewAutomation(automationId) {
const notificationEl = document.getElementById(`automation-success-${automationId}`);
fetch(`/api/trigger/automation?automation_id=${automationId}`, { method: 'POST' })
.then(response =>
{
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(automations => {
notificationEl.style.display = 'block';
notificationEl.textContent = "Automation triggered. Check your inbox in a few minutes!";
})
.catch(error => {
notificationEl.style.display = 'block';
notificationEl.textContent = "Sorry, something went wrong. Try again later."
})
}
function clickCancelEdit(event, automationId) {
event.preventDefault();
event.stopPropagation();
const automationIDElements = document.querySelectorAll(`.${automationId}`);
automationIDElements.forEach(el => {
if (el.classList.contains("automation-edit-cancel-icon")) {
el.classList.remove("automation-edit-cancel-icon");
el.classList.add("automation-edit-icon");
el.src = "/static/assets/icons/pencil-edit.svg";
el.onclick = function() { onClickEditAutomationCard(automationId); };
}
if (el.classList.contains("hide-details-placeholder")) {
el.classList.remove("hide-details-placeholder");
el.classList.add("hide-details");
}
if (el.classList.contains("fake-input-placeholder")) {
el.classList.remove("fake-input-placeholder");
el.classList.add("fake-input");
}
})
}
function generateAutomationRow(automation, isSuggested=false) { function generateAutomationRow(automation, isSuggested=false) {
let automationId = automation.id; let automationId = automation.id;
let automationNextRun = `Next run at ${automation.next}\nCron: ${automation.crontime}`; let automationNextRun = `Next run at ${automation.next}\nCron: ${automation.crontime}`;
let automationEl = document.createElement("div"); let automationEl = document.createElement("div");
automationEl.innerHTML = ` automationEl.innerHTML = `
<div class="card automation" id="automation-card-${automationId}"> <div class="card automation" id="automation-card-${automationId}">
<div class="card-header" onclick="onClickAutomationCard('${automationId}')"> <div class="card-header" onclick="onClickEditAutomationCard('${automationId}')">
<input type="text" <div class="subject-wrapper">
id="automation-subject-${automationId}" <input type="text"
class="${automationId} fake-input" id="automation-subject-${automationId}"
name="subject" class="${automationId} fake-input"
data-original="${automation.subject}" name="subject"
value="${automation.subject}"> data-original="${automation.subject}"
value="${automation.subject}">
<img class="automation-edit-icon ${automationId}" src="/static/assets/icons/pencil-edit.svg" onclick="onClickEditAutomationCard('${automationId}')" alt="Automations">
</div>
<input type="text" <input type="text"
id="automation-schedule-${automationId}" id="automation-schedule-${automationId}"
name="schedule" name="schedule"
@ -276,13 +371,17 @@
</div> </div>
<div id="automation-buttons-wrapper"> <div id="automation-buttons-wrapper">
<div class="automation-buttons"> <div class="automation-buttons">
<div id="empty-div"></div>
${isSuggested ? ${isSuggested ?
`<div id="empty-div"></div>`: `<div id="empty-div"></div>
<div id="empty-div"></div>`:
` `
<button type="button" <button type="button"
class="delete-automation-button negative-button" class="delete-automation-button negative-button"
id="delete-automation-button-${automationId}">Delete</button> id="delete-automation-button-${automationId}">Delete</button>
<button type="button"
class="send-preview-automation-button positive-button"
title="Immediately get a preview of this automation"
onclick="sendAPreviewAutomation('${automationId}')">Preview</button>
` `
} }
<button type="button" <button type="button"
@ -308,6 +407,10 @@
if (deleteAutomationButtonEl) { if (deleteAutomationButtonEl) {
deleteAutomationButtonEl.addEventListener("click", () => { deleteAutomation(automation.id); }); deleteAutomationButtonEl.addEventListener("click", () => { deleteAutomation(automation.id); });
} }
let cancelEditAutomationButtonEl = automationEl.querySelector(`#cancel-edit-automation-button-${automation.id}`);
if (cancelEditAutomationButtonEl) {
cancelEditAutomationButtonEl.addEventListener("click", (event) => { clickCancelEdit(event, automation.id); });
}
return automationEl.firstElementChild; return automationEl.firstElementChild;
} }
@ -317,7 +420,7 @@
let suggestedAutomationsMetadata = [ let suggestedAutomationsMetadata = [
{ {
"subject": "Weekly Newsletter", "subject": "Weekly Newsletter",
"query_to_run": "Share a Newsletter including: 1. Weather forecast for this week. 2. A recap of news from last week 3. A nice quote to start the week.", "query_to_run": "Compile a message including: 1. A recap of news from last week 2. A reminder to work out and stay hydrated 3. A quote to inspire me for the week ahead",
"schedule": "9AM every Monday", "schedule": "9AM every Monday",
"next": "Next run at 9AM on Monday", "next": "Next run at 9AM on Monday",
"crontime": "0 9 * * 1", "crontime": "0 9 * * 1",
@ -326,7 +429,7 @@
}, },
{ {
"subject": "Daily Weather Update", "subject": "Daily Weather Update",
"query_to_run": "Get the weather forecast for today and tomorrow", "query_to_run": "Get the weather forecast for today",
"schedule": "9AM every morning", "schedule": "9AM every morning",
"next": "Next run at 9AM today", "next": "Next run at 9AM today",
"crontime": "0 9 * * *", "crontime": "0 9 * * *",
@ -413,35 +516,250 @@
}); });
} }
function createScheduleSelector(automationId) {
var scheduleContainer = document.createElement('div');
scheduleContainer.id = `schedule-container-${automationId}`;
var frequencyLabel = document.createElement('label');
frequencyLabel.for = `frequency-selector-${automationId}`;
frequencyLabel.textContent = 'Every';
var frequencySelector = document.createElement('select')
frequencySelector.id = `frequency-selector-${automationId}`;
var dayLabel = document.createElement('label');
dayLabel.id = `day-selector-label-${automationId}`;
dayLabel.for = `day-selector-${automationId}`;
dayLabel.textContent = 'on';
var daySelector = document.createElement('select');
daySelector.id = `day-selector-${automationId}`;
var dateLabel = document.createElement('label');
dateLabel.id = `date-label-${automationId}`;
dateLabel.for = `date-selector-${automationId}`;
dateLabel.textContent = 'on the';
var dateSelector = document.createElement('select');
dateSelector.id = `date-selector-${automationId}`;
var timeLabel = document.createElement('label');
timeLabel.for = `time-selector-${automationId}`;
timeLabel.textContent = 'at';
var timeSelector = document.createElement('select');
timeSelector.id = `time-selector-${automationId}`;
// Populate frequency selector with options for day, week, and month
var frequencies = ['day', 'week', 'month'];
for (var i = 0; i < frequencies.length; i++) {
var option = document.createElement('option');
option.value = frequencies[i];
option.text = frequencies[i];
frequencySelector.appendChild(option);
}
// Event listener for frequency selector change
frequencySelector.addEventListener('change', function() {
switch (this.value) {
case 'day':
daySelector.style.display = 'none';
dateSelector.style.display = 'none';
break;
case 'week':
daySelector.style.display = 'block';
dateSelector.style.display = 'none';
break;
case 'month':
daySelector.style.display = 'none';
dateSelector.style.display = 'block';
break;
}
});
// Populate the date selector with options for each day of the month
for (var i = 1; i <= 31; i++) {
var option = document.createElement('option');
option.value = i;
option.text = i;
dateSelector.appendChild(option);
}
// Populate the day selector with options for each day of the week
var days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
for (var i = 0; i < days.length; i++) {
var option = document.createElement('option');
option.value = i;
option.text = days[i];
daySelector.appendChild(option);
}
var timePeriods = ['AM', 'PM'];
// Populate the time selector with options for each hour of the day
for (var i = 0; i < timePeriods.length; i++) {
for (var hour = 0; hour < 12; hour++) {
for (var minute = 0; minute < 60; minute+=15) {
// Ensure all minutes are two digits
var paddedMinute = String(minute).padStart(2, '0');
var option = document.createElement('option');
var friendlyHour = hour === 0 ? 12 : hour;
option.value = `${friendlyHour}:${paddedMinute} ${timePeriods[i]}`;
option.text = `${friendlyHour}:${paddedMinute} ${timePeriods[i]}`;
timeSelector.appendChild(option);
}
}
}
// Populate date selector with options 1 through 31
for (var i = 1; i <= 31; i++) {
var option = document.createElement('option');
option.value = i;
option.text = i;
dateSelector.appendChild(option);
}
var hoursMinutesSelectorContainer = document.createElement('div');
hoursMinutesSelectorContainer.classList.add('hours-minutes-selector-container');
hoursMinutesSelectorContainer.appendChild(timeLabel);
hoursMinutesSelectorContainer.appendChild(timeSelector);
scheduleContainer.appendChild(frequencyLabel);
scheduleContainer.appendChild(frequencySelector);
scheduleContainer.appendChild(dayLabel);
scheduleContainer.appendChild(daySelector);
scheduleContainer.appendChild(dateLabel);
scheduleContainer.appendChild(dateSelector);
scheduleContainer.appendChild(hoursMinutesSelectorContainer);
return scheduleContainer;
}
function setupScheduleViewListener(cronString, automationId) {
// Parse the cron string
var cronParts = cronString.split(' ');
var minutes = cronParts[0];
var hours = cronParts[1];
var dayOfMonth = cronParts[2];
var month = cronParts[3];
var dayOfWeek = cronParts[4];
var timeSelector = document.getElementById(`time-selector-${automationId}`);
// Set the initial value of the time selector based on the cron string. Convert 24-hour time to 12-hour time
if (hours === '*' && minutes === '*') {
var currentTime = new Date();
hours = currentTime.getHours();
minutes = currentTime.getMinutes();
}
var hours = parseInt(hours);
var minutes = parseInt(minutes);
var timePeriod = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12; // 0 should be 12
minutes = Math.round(minutes / 15) * 15;
minutes = String(minutes).padStart(2, '0');
// Resolve minutes to the nearest 15 minute interval
timeSelector.value = `${hours}:${minutes} ${timePeriod}`;
const frequencySelector = document.getElementById(`frequency-selector-${automationId}`);
const daySelector = document.getElementById(`day-selector-${automationId}`);
const daySelectorLabel = document.getElementById(`day-selector-label-${automationId}`);
const dateSelector = document.getElementById(`date-selector-${automationId}`);
const dateLabel = document.getElementById(`date-label-${automationId}`);
// Event listener for frequency selector change
frequencySelector.addEventListener('change', function() {
processFrequencySelector(frequencySelector, daySelector, daySelectorLabel, dateSelector, dateLabel);
});
// Set the initial value based on the frequency selector value
processFrequencySelector(frequencySelector, daySelector, daySelectorLabel, dateSelector, dateLabel);
}
function processFrequencySelector(frequencySelector, daySelector, daySelectorLabel, dateSelector, dateLabel) {
switch (frequencySelector.value) {
case 'day':
daySelector.style.display = 'none';
dateSelector.style.display = 'none';
daySelectorLabel.style.display = 'none';
dateLabel.style.display = 'none';
break;
case 'week':
daySelector.style.display = 'block';
dateSelector.style.display = 'none';
daySelectorLabel.style.display = 'block';
dateLabel.style.display = 'none';
break;
case 'month':
daySelector.style.display = 'none';
dateSelector.style.display = 'block';
daySelectorLabel.style.display = 'none';
dateLabel.style.display = 'block';
break;
}
}
function convertFrequencyToCron(automationId) {
var frequencySelector = document.getElementById(`frequency-selector-${automationId}`);
var daySelector = document.getElementById(`day-selector-${automationId}`);
var dateSelector = document.getElementById(`date-selector-${automationId}`);
var timeSelector = document.getElementById(`time-selector-${automationId}`);
var hours = timeSelector.value.split(':')[0];
var minutes = timeSelector.value.split(':')[1].split(' ')[0];
var cronString = '';
switch (frequencySelector.value) {
case 'day':
cronString = `${minutes} ${hours} * * *`;
break;
case 'week':
cronString = `${minutes} ${hours} * * ${daySelector.value}`;
break;
case 'month':
cronString = `${minutes} ${hours} ${dateSelector.value} * *`;
break;
}
return cronString;
}
async function saveAutomation(automationId, create=false) { async function saveAutomation(automationId, create=false) {
const scheduleEl = document.getElementById(`automation-schedule-${automationId}`); const scheduleEl = document.getElementById(`automation-schedule-${automationId}`);
const notificationEl = document.getElementById(`automation-success-${automationId}`); const notificationEl = document.getElementById(`automation-success-${automationId}`);
const saveButtonEl = document.getElementById(`save-automation-button-${automationId}`); const saveButtonEl = document.getElementById(`save-automation-button-${automationId}`);
const queryToRunEl = document.getElementById(`automation-queryToRun-${automationId}`); const queryToRunEl = document.getElementById(`automation-queryToRun-${automationId}`);
const queryToRun = encodeURIComponent(queryToRunEl.value); const queryToRun = encodeURIComponent(queryToRunEl.value);
const actOn = create ? "Create" : "Save"; const actOn = create ? "Creat" : "Sav";
var cronTime = null;
if (queryToRun == "" || scheduleEl.value == "") { if (queryToRun == "") {
notificationEl.textContent = `⚠️ Failed to automate. All input fields need to be filled.`; if (!create && scheduleEl.value == "") {
notificationEl.style.display = "block"; notificationEl.textContent = `⚠️ Failed to automate. All input fields need to be filled.`;
let originalQueryToRunElBorder = queryToRunEl.style.border; notificationEl.style.display = "block";
if (queryToRun === "") queryToRunEl.style.border = "2px solid red"; let originalQueryToRunElBorder = queryToRunEl.style.border;
let originalScheduleElBorder = scheduleEl.style.border; if (queryToRun === "") queryToRunEl.style.border = "2px solid red";
if (scheduleEl.value === "") scheduleEl.style.border = "2px solid red"; let originalScheduleElBorder = scheduleEl.style.border;
setTimeout(function() { if (scheduleEl.value === "") scheduleEl.style.border = "2px solid red";
if (queryToRun == "") queryToRunEl.style.border = originalQueryToRunElBorder; setTimeout(function() {
if (scheduleEl.value == "") scheduleEl.style.border = originalScheduleElBorder; if (queryToRun == "") queryToRunEl.style.border = originalQueryToRunElBorder;
}, 2000); if (scheduleEl.value == "") scheduleEl.style.border = originalScheduleElBorder;
}, 2000);
return;
}
return;
} }
// Get client location information from IP // Get client location information from IP
const ip_response = await fetch("https://ipapi.co/json") const ip_response = await fetch("https://ipapi.co/json");
const ip_data = await ip_response.json(); let ip_data = null;
if (ip_response.ok) {
ip_data = await ip_response.json();
}
// Get cron string from natural language user schedule, if changed // Get cron string from natural language user schedule, if changed
const crontime = scheduleEl.getAttribute('data-original') !== scheduleEl.value ? getCronString(scheduleEl.value) : scheduleEl.getAttribute('data-cron'); if (create && !scheduleEl) {
crontime = convertFrequencyToCron(automationId);
} else {
crontime = scheduleEl.getAttribute('data-original') !== scheduleEl.value ? getCronString(scheduleEl.value) : scheduleEl.getAttribute('data-cron');
}
if (crontime.startsWith("ERROR:")) { if (crontime.startsWith("ERROR:")) {
notificationEl.textContent = `⚠️ Failed to automate. Fix or simplify Schedule input field.`; notificationEl.textContent = `⚠️ Failed to automate. Fix or simplify Schedule input field.`;
notificationEl.style.display = "block"; notificationEl.style.display = "block";
@ -456,7 +774,10 @@
const encodedCrontime = encodeURIComponent(crontime); const encodedCrontime = encodeURIComponent(crontime);
// Construct query string and select method for API call // Construct query string and select method for API call
let query_string = `q=${queryToRun}&crontime=${encodedCrontime}&city=${ip_data.city}&region=${ip_data.region}&country=${ip_data.country_name}&timezone=${ip_data.timezone}`; let query_string = `q=${queryToRun}&crontime=${encodedCrontime}`;
if (ip_data) {
query_string += `&city=${ip_data.city}&region=${ip_data.region}&country=${ip_data.country_name}&timezone=${ip_data.timezone}`;
}
let method = "POST"; let method = "POST";
if (!create) { if (!create) {
@ -466,6 +787,11 @@
method = "PUT" method = "PUT"
} }
// Create a loading animation while waiting for the API response
// TODO add a more pleasant loading symbol here.
notificationEl.textContent = `⏳ ${actOn}ing automation...`;
notificationEl.style.display = "block";
fetch(`/api/automation?${query_string}`, { fetch(`/api/automation?${query_string}`, {
method: method, method: method,
headers: { headers: {
@ -477,9 +803,8 @@
if (create) { if (create) {
const automationEl = document.getElementById(`automation-card-${automationId}`); const automationEl = document.getElementById(`automation-card-${automationId}`);
// Create a more interesting confirmation animation. // Create a more interesting confirmation animation.
automationEl.classList.add("confirmation") automationEl.classList.remove("new-automation");
setTimeout(function() { setTimeout(function() {
// Check if automationEl is a child of #automations or #suggested-automations-list // Check if automationEl is a child of #automations or #suggested-automations-list
// If #suggested-automations-list, remove the element from the list and add it to #automations // If #suggested-automations-list, remove the element from the list and add it to #automations
let parentEl = automationEl.parentElement; let parentEl = automationEl.parentElement;
@ -495,7 +820,7 @@
} }
notificationEl.style.display = "none"; notificationEl.style.display = "none";
saveButtonEl.textContent = `✅ Automation ${actOn}d`; saveButtonEl.textContent = `✅ Automation ${actOn}ed`;
setTimeout(function() { setTimeout(function() {
const automationIDElements = document.querySelectorAll(`.${automationId}`); const automationIDElements = document.querySelectorAll(`.${automationId}`);
automationIDElements.forEach(el => { automationIDElements.forEach(el => {
@ -514,11 +839,11 @@
}, 2000); }, 2000);
}) })
.catch(error => { .catch(error => {
notificationEl.textContent = `⚠️ Failed to ${actOn.toLowerCase()} automations.`; notificationEl.textContent = `⚠️ Failed to ${actOn.toLowerCase()}e automations.`;
notificationEl.style.display = "block"; notificationEl.style.display = "block";
saveButtonEl.textContent = `⚠️ Failed to ${actOn.toLowerCase()} automations`; saveButtonEl.textContent = `⚠️ Failed to ${actOn.toLowerCase()}e automations`;
setTimeout(function() { setTimeout(function() {
saveButtonEl.textContent = actOn; saveButtonEl.textContent = `${actOn}e`;
}, 2000); }, 2000);
return; return;
}); });
@ -533,13 +858,11 @@
automationEl.classList.add("new-automation") automationEl.classList.add("new-automation")
const placeholderId = Date.now(); const placeholderId = Date.now();
automationEl.id = "automation-card-" + placeholderId; automationEl.id = "automation-card-" + placeholderId;
var scheduleSelector = createScheduleSelector(placeholderId);
automationEl.innerHTML = ` automationEl.innerHTML = `
<label for="schedule">Schedule</label> <label for="schedule">New Automation</label>
<input type="text" ${scheduleSelector.outerHTML}
id="automation-schedule-${placeholderId}" <label for="query-to-run">What would you like to receive in your automation?</label>
name="schedule"
placeholder="9AM every morning">
<label for="query-to-run">Instructions</label>
<textarea id="automation-queryToRun-${placeholderId}" placeholder="Provide me with a mindful moment, reminding me to be centered."></textarea> <textarea id="automation-queryToRun-${placeholderId}" placeholder="Provide me with a mindful moment, reminding me to be centered."></textarea>
<div class="automation-buttons"> <div class="automation-buttons">
<button type="button" <button type="button"
@ -554,6 +877,7 @@
<div id="automation-success-${placeholderId}" style="display: none;"></div> <div id="automation-success-${placeholderId}" style="display: none;"></div>
`; `;
document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild); document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild);
setupScheduleViewListener("* * * * *", placeholderId);
}) })
</script> </script>
{% endblock %} {% endblock %}

View file

@ -3,6 +3,7 @@ import json
import logging import logging
import math import math
import os import os
import threading
import time import time
import uuid import uuid
from typing import Any, Callable, List, Optional, Union from typing import Any, Callable, List, Optional, Union
@ -36,6 +37,7 @@ from khoj.routers.helpers import (
ConversationCommandRateLimiter, ConversationCommandRateLimiter,
acreate_title_from_query, acreate_title_from_query,
schedule_automation, schedule_automation,
scheduled_chat,
update_telemetry_state, update_telemetry_state,
) )
from khoj.search_filter.date_filter import DateFilter from khoj.search_filter.date_filter import DateFilter
@ -452,12 +454,19 @@ async def post_automation(
crontime = " ".join(crontime.split(" ")[:5]) crontime = " ".join(crontime.split(" ")[:5])
# Convert crontime to standard unix crontime # Convert crontime to standard unix crontime
crontime = crontime.replace("?", "*") 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) 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 # Schedule automation with query_to_run, timezone, subject directly provided by user
try: try:
# Use the query to run as the scheduling request if the scheduling request is unset # 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: except Exception as e:
logger.error(f"Error creating automation {q} for {user.email}: {e}", exc_info=True) logger.error(f"Error creating automation {q} for {user.email}: {e}", exc_info=True)
return Response( return Response(
@ -473,6 +482,31 @@ async def post_automation(
return Response(content=json.dumps(automation_info), media_type="application/json", status_code=200) 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) @api.put("/automation", response_class=Response)
@requires(["authenticated"]) @requires(["authenticated"])
def edit_job( def edit_job(

View file

@ -956,6 +956,8 @@ def scheduled_chat(
async def create_automation(q: str, timezone: str, user: KhojUser, calling_url: URL, meta_log: dict = {}): 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) 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) job = await schedule_automation(query_to_run, subject, crontime, timezone, q, user, calling_url)
return job, crontime, query_to_run, subject return job, crontime, query_to_run, subject
@ -970,6 +972,8 @@ async def schedule_automation(
calling_url: URL, calling_url: URL,
): ):
user_timezone = pytz.timezone(timezone) 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 = CronTrigger.from_crontab(crontime, user_timezone)
trigger.jitter = 60 trigger.jitter = 60
# Generate id and metadata used by task scheduler and process locks for the task runs # Generate id and metadata used by task scheduler and process locks for the task runs

View file

@ -11,7 +11,6 @@ from starlette.authentication import has_required_scope, requires
from khoj.database import adapters from khoj.database import adapters
from khoj.database.adapters import ( from khoj.database.adapters import (
AgentAdapters, AgentAdapters,
AutomationAdapters,
ConversationAdapters, ConversationAdapters,
EntryAdapters, EntryAdapters,
PublicConversationAdapters, PublicConversationAdapters,