mirror of
https://github.com/khoj-ai/khoj.git
synced 2024-11-27 17:35:07 +01:00
Allow automations to be shareable (#790)
* Updating the API / UI to support sharing of automations * Allow people to see the automations even when not logged in, and add an overlay effect * Handle unauthenticated users taking actions * Support showing pre-filled automation details on the config automations page * Redirect user to login if they try to add an automation while unauthenticated
This commit is contained in:
parent
2667ef4544
commit
5ec641837a
5 changed files with 146 additions and 11 deletions
|
@ -32,7 +32,10 @@
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById("settings-nav").classList.add("khoj-nav-selected");
|
const settingsNav = document.getElementById("settings-nav");
|
||||||
|
if (settingsNav) {
|
||||||
|
settingsNav.classList.add("khoj-nav-selected");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body {
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
<div id="overlay" style="display: none;"></div>
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<img class="card-icon" src="/static/assets/icons/automation.svg?v={{ khoj_version }}" alt="Automate">
|
<img class="card-icon" src="/static/assets/icons/automation.svg?v={{ khoj_version }}" alt="Automate">
|
||||||
<span class="card-title-text">Automate (Preview)</span>
|
<span class="card-title-text">Automate (Preview)</span>
|
||||||
|
@ -35,6 +36,16 @@
|
||||||
td {
|
td {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
#overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: black;
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
div.automation {
|
div.automation {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -62,6 +73,7 @@
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img.automation-share-icon,
|
||||||
img.automation-edit-cancel-icon,
|
img.automation-edit-cancel-icon,
|
||||||
img.automation-edit-icon {
|
img.automation-edit-icon {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
@ -204,7 +216,7 @@
|
||||||
|
|
||||||
div.subject-wrapper {
|
div.subject-wrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto auto;
|
||||||
grid-gap: 8px;
|
grid-gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -240,6 +252,14 @@
|
||||||
<script>
|
<script>
|
||||||
function deleteAutomation(automationId) {
|
function deleteAutomation(automationId) {
|
||||||
const AutomationList = document.getElementById("automations");
|
const AutomationList = document.getElementById("automations");
|
||||||
|
if ("{{ username }}" === "None") {
|
||||||
|
const AutomationItem = document.getElementById(`automation-card-${automationId}`);
|
||||||
|
AutomationList.removeChild(AutomationItem);
|
||||||
|
// Remove the Automation from the DOM and return
|
||||||
|
AutomationItem.remove();
|
||||||
|
document.getElementById('overlay').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetch(`/api/automation?automation_id=${automationId}`, {
|
fetch(`/api/automation?automation_id=${automationId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
|
@ -247,6 +267,7 @@
|
||||||
if (response.status == 200 || response.status == 204) {
|
if (response.status == 200 || response.status == 204) {
|
||||||
const AutomationItem = document.getElementById(`automation-card-${automationId}`);
|
const AutomationItem = document.getElementById(`automation-card-${automationId}`);
|
||||||
AutomationList.removeChild(AutomationItem);
|
AutomationList.removeChild(AutomationItem);
|
||||||
|
document.getElementById('overlay').style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -336,6 +357,35 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeShareModal(automationId) {
|
||||||
|
const modal = document.getElementById(`share-modal-${automationId}`);
|
||||||
|
modal.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyShareLink(event, automationId, subject, crontime, queryToRun) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const encodedSubject = encodeURIComponent(subject);
|
||||||
|
const encodedCrontime = encodeURIComponent(crontime);
|
||||||
|
const encodedQueryToRun = encodeURIComponent(queryToRun);
|
||||||
|
|
||||||
|
const shareLink = `${window.location.origin}/automations?subject=${encodedSubject}&crontime=${encodedCrontime}&queryToRun=${encodedQueryToRun}`;
|
||||||
|
const button = document.getElementById(`share-link-${automationId}`);
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(shareLink).then(() => {
|
||||||
|
button.src = "/static/assets/icons/copy-button-success.svg";
|
||||||
|
setTimeout(() => {
|
||||||
|
button.src = "/static/assets/icons/share.svg";
|
||||||
|
}, 1000);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("Error copying share link output to clipboard:", error);
|
||||||
|
setTimeout(() => {
|
||||||
|
button.src = "/static/assets/icons/share.svg";
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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}`;
|
||||||
|
@ -350,6 +400,7 @@
|
||||||
name="subject"
|
name="subject"
|
||||||
data-original="${automation.subject}"
|
data-original="${automation.subject}"
|
||||||
value="${automation.subject}">
|
value="${automation.subject}">
|
||||||
|
<img class=automation-share-icon id="share-link-${automationId}" src="/static/assets/icons/share.svg" alt="Share" onclick="copyShareLink(event, '${automationId}', '${automation.subject}', '${automation.crontime}', '${automation.query_to_run}')">
|
||||||
<img class="automation-edit-icon ${automationId}" src="/static/assets/icons/pencil-edit.svg" onclick="onClickEditAutomationCard('${automationId}')" alt="Automations">
|
<img class="automation-edit-icon ${automationId}" src="/static/assets/icons/pencil-edit.svg" onclick="onClickEditAutomationCard('${automationId}')" alt="Automations">
|
||||||
</div>
|
</div>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
@ -405,11 +456,17 @@
|
||||||
saveAutomationButtonEl.addEventListener("click", async () => { await saveAutomation(automation.id, isSuggested); });
|
saveAutomationButtonEl.addEventListener("click", async () => { await saveAutomation(automation.id, isSuggested); });
|
||||||
let deleteAutomationButtonEl = automationEl.querySelector(`#delete-automation-button-${automation.id}`);
|
let deleteAutomationButtonEl = automationEl.querySelector(`#delete-automation-button-${automation.id}`);
|
||||||
if (deleteAutomationButtonEl) {
|
if (deleteAutomationButtonEl) {
|
||||||
deleteAutomationButtonEl.addEventListener("click", () => { deleteAutomation(automation.id); });
|
deleteAutomationButtonEl.addEventListener("click", () => {
|
||||||
|
deleteAutomation(automation.id);
|
||||||
|
document.getElementById('overlay').style.display = 'none';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let cancelEditAutomationButtonEl = automationEl.querySelector(`#cancel-edit-automation-button-${automation.id}`);
|
let cancelEditAutomationButtonEl = automationEl.querySelector(`#cancel-edit-automation-button-${automation.id}`);
|
||||||
if (cancelEditAutomationButtonEl) {
|
if (cancelEditAutomationButtonEl) {
|
||||||
cancelEditAutomationButtonEl.addEventListener("click", (event) => { clickCancelEdit(event, automation.id); });
|
cancelEditAutomationButtonEl.addEventListener("click", (event) => {
|
||||||
|
clickCancelEdit(event, automation.id);
|
||||||
|
document.getElementById('overlay').style.display = 'none';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return automationEl.firstElementChild;
|
return automationEl.firstElementChild;
|
||||||
|
@ -473,9 +530,29 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Check if subject, crontime, query_to_run are all filled out. If so, show it as a populated suggested automation.
|
||||||
|
const subject = "{{ subject }}";
|
||||||
|
const crontime = "{{ crontime }}";
|
||||||
|
const query = "{{ queryToRun }}";
|
||||||
|
|
||||||
|
if (subject && crontime && query) {
|
||||||
|
const preFilledAutomation = createPreFilledAutomation(subject, crontime, query);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("{{ username }}" !== "None") {
|
||||||
listAutomations();
|
listAutomations();
|
||||||
|
} else {
|
||||||
|
// Check if subject, crontime, query_to_run are all filled out. If so, show it as a populated suggested automation.
|
||||||
|
const subject = "{{ subject }}";
|
||||||
|
const crontime = "{{ crontime }}";
|
||||||
|
const query = "{{ queryToRun }}";
|
||||||
|
|
||||||
|
if (subject && crontime && query) {
|
||||||
|
const preFilledAutomation = createPreFilledAutomation(subject, crontime, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (suggestedAutomationsMetadata.length > 0) {
|
if (suggestedAutomationsMetadata.length > 0) {
|
||||||
suggestedAutomationsMetadata.forEach(automation => {
|
suggestedAutomationsMetadata.forEach(automation => {
|
||||||
|
@ -720,6 +797,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAutomation(automationId, create=false) {
|
async function saveAutomation(automationId, create=false) {
|
||||||
|
if ("{{ username }}" == "None") {
|
||||||
|
url_encoded_href = encodeURIComponent(window.location.href);
|
||||||
|
window.location.href = `/login?next=${url_encoded_href}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
|
@ -879,5 +962,38 @@
|
||||||
document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild);
|
document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild);
|
||||||
setupScheduleViewListener("* * * * *", placeholderId);
|
setupScheduleViewListener("* * * * *", placeholderId);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function createPreFilledAutomation(subject, crontime, query) {
|
||||||
|
document.getElementById('overlay').style.display = 'block';
|
||||||
|
var automationEl = document.createElement("div");
|
||||||
|
automationEl.classList.add("card");
|
||||||
|
automationEl.classList.add("automation");
|
||||||
|
automationEl.classList.add("new-automation")
|
||||||
|
const placeholderId = Date.now();
|
||||||
|
automationEl.classList.add(`${placeholderId}`);
|
||||||
|
automationEl.id = "automation-card-" + placeholderId;
|
||||||
|
var scheduleSelector = createScheduleSelector(placeholderId);
|
||||||
|
automationEl.innerHTML = `
|
||||||
|
<label for="subject">New Automation</label>
|
||||||
|
<input type="text" id="automation-subject-${placeholderId}" value="${subject}">
|
||||||
|
${scheduleSelector.outerHTML}
|
||||||
|
<label for="query-to-run">What would you like to receive in your automation?</label>
|
||||||
|
<textarea id="automation-queryToRun-${placeholderId}">${query}</textarea>
|
||||||
|
<div class="automation-buttons">
|
||||||
|
<button type="button"
|
||||||
|
class="delete-automation-button negative-button"
|
||||||
|
onclick="deleteAutomation(${placeholderId}, true)"
|
||||||
|
id="delete-automation-button-${placeholderId}">Cancel</button>
|
||||||
|
<button type="button"
|
||||||
|
class="save-automation-button"
|
||||||
|
onclick="saveAutomation(${placeholderId}, true)"
|
||||||
|
id="save-automation-button-${placeholderId}">Create</button>
|
||||||
|
</div>
|
||||||
|
<div id="automation-success-${placeholderId}" style="display: none;"></div>
|
||||||
|
`;
|
||||||
|
document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild);
|
||||||
|
setupScheduleViewListener(crontime, placeholderId);
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -443,6 +443,7 @@ async def post_automation(
|
||||||
request: Request,
|
request: Request,
|
||||||
q: str,
|
q: str,
|
||||||
crontime: str,
|
crontime: str,
|
||||||
|
subject: Optional[str] = None,
|
||||||
city: Optional[str] = None,
|
city: Optional[str] = None,
|
||||||
region: Optional[str] = None,
|
region: Optional[str] = None,
|
||||||
country: Optional[str] = None,
|
country: Optional[str] = None,
|
||||||
|
@ -461,11 +462,13 @@ async def post_automation(
|
||||||
q = q.strip()
|
q = q.strip()
|
||||||
if not q.startswith("/automated_task"):
|
if not q.startswith("/automated_task"):
|
||||||
query_to_run = f"/automated_task {q}"
|
query_to_run = f"/automated_task {q}"
|
||||||
|
|
||||||
# Normalize crontime for AP Scheduler CronTrigger
|
# Normalize crontime for AP Scheduler CronTrigger
|
||||||
crontime = crontime.strip()
|
crontime = crontime.strip()
|
||||||
if len(crontime.split(" ")) > 5:
|
if len(crontime.split(" ")) > 5:
|
||||||
# Truncate crontime to 5 fields
|
# Truncate crontime to 5 fields
|
||||||
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("?", "*")
|
||||||
|
|
||||||
|
@ -477,6 +480,7 @@ async def post_automation(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not subject:
|
||||||
subject = await acreate_title_from_query(q)
|
subject = await acreate_title_from_query(q)
|
||||||
|
|
||||||
# Create new Conversation Session associated with this new task
|
# Create new Conversation Session associated with this new task
|
||||||
|
|
|
@ -95,6 +95,10 @@ async def delete_token(request: Request, token: str):
|
||||||
async def auth(request: Request):
|
async def auth(request: Request):
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
next_url = request.query_params.get("next", "/")
|
next_url = request.query_params.get("next", "/")
|
||||||
|
for q in request.query_params:
|
||||||
|
if not q == "next":
|
||||||
|
next_url += f"&{q}={request.query_params[q]}"
|
||||||
|
|
||||||
credential = form.get("credential")
|
credential = form.get("credential")
|
||||||
|
|
||||||
csrf_token_cookie = request.cookies.get("g_csrf_token")
|
csrf_token_cookie = request.cookies.get("g_csrf_token")
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
|
@ -421,20 +422,27 @@ def view_public_conversation(request: Request):
|
||||||
|
|
||||||
|
|
||||||
@web_client.get("/automations", response_class=HTMLResponse)
|
@web_client.get("/automations", response_class=HTMLResponse)
|
||||||
@requires(["authenticated"], redirect="login_page")
|
def automations_config_page(
|
||||||
def automations_config_page(request: Request):
|
request: Request,
|
||||||
user = request.user.object
|
subject: Optional[str] = None,
|
||||||
|
crontime: Optional[str] = None,
|
||||||
|
queryToRun: Optional[str] = None,
|
||||||
|
):
|
||||||
|
user = request.user.object if request.user.is_authenticated else None
|
||||||
user_picture = request.session.get("user", {}).get("picture")
|
user_picture = request.session.get("user", {}).get("picture")
|
||||||
has_documents = EntryAdapters.user_has_entries(user=user)
|
has_documents = EntryAdapters.user_has_entries(user=user) if user else False
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"config_automation.html",
|
"config_automation.html",
|
||||||
context={
|
context={
|
||||||
"request": request,
|
"request": request,
|
||||||
"username": user.username,
|
"username": user.username if user else None,
|
||||||
"user_photo": user_picture,
|
"user_photo": user_picture,
|
||||||
"is_active": has_required_scope(request, ["premium"]),
|
"is_active": has_required_scope(request, ["premium"]),
|
||||||
"has_documents": has_documents,
|
"has_documents": has_documents,
|
||||||
"khoj_version": state.khoj_version,
|
"khoj_version": state.khoj_version,
|
||||||
|
"subject": subject if subject else "",
|
||||||
|
"crontime": crontime if crontime else "",
|
||||||
|
"queryToRun": queryToRun if queryToRun else "",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue