Add OS Level Shortcut Window for Quick Access to Khoj Desktop (#815)

* rough sketch of desktop shortcuts. many bugs to fix still

* working MVP of desktop shortcut khoj

* UI fixes

* UI improvements for editable shortcut message

* major rendering fix to prevent clipboard text from getting lost

* UI improvements and bug fixes

* UI upgrades: custom top bar, edit sent message and color matching

* removed debug javascript file

* font reverted to Noto Sans

* cleaning up the code and removing diffs

* UX fixes

* cleaning up unused methods from html

* front end for button to send user back to main window to continue conversation

* UX fix for window and continue conversation support added

* migrated common js functions into chatutils.js

* Fix window closing issue in macos by

1. Use a helper function to determine if the window is open by seeing if there's a browser window with shortcut.html loaded
2. Use the  event listener on the window to handle teardown

* removed extra comment and renamed continue convo button

---------

Co-authored-by: sabaimran <narmiabas@gmail.com>
This commit is contained in:
Raghav Tirumale
2024-06-27 10:20:13 -04:00
committed by GitHub
parent 870d9ecdbf
commit 24a0d8b073
5 changed files with 981 additions and 351 deletions

View File

@@ -0,0 +1,518 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Khoj Mini</title>
<style>
#title-bar {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 20px;
background-color: #f9f5de;
color: black;
-webkit-app-region: drag;
z-index: 9999;
margin: 0;
padding-left: 7px;
padding-right: 7px;
padding-top: 5px;
padding-bottom: 5px;
font-family: 'Noto Sans', sans-serif;
}
#loading-dots {
padding-top: 10px;
text-align: center;
font-size: 16px;
font-family: 'Noto Sans', sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
#styled-input {
width: 100%;
font-size: 14px;
height: 100px;
min-height: 50px;
background-color: #475569; /* Blue background */
color: #dcdfe4; /* White text */
font-family: 'Noto Sans', sans-serif;
border: none;
resize: vertical;
overflow: hidden;
}
.chat-input {
margin-top: 10px;
position: fixed;
bottom: 0;
left: 5;
width: 100%;
max-width: 600px;
display: flex;
padding-top: 10px;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
}
#input-container {
background-color: #475569;
border-radius: 5px;
padding: 10px;
margin-top: 50px;
}
#send-button {
border: none;
border-radius: 5px 5px 5px 5px;
margin-top: 5px;
background-color: #5a6b84;
color: white;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
font-family: 'Noto Sans', sans-serif;
/* font-weight: bold; */
position: relative;
margin-right: 10px;
}
#send-button:hover {
background: #7489a9;
}
#edit-button {
border: none;
border-radius: 5px 5px 5px 5px;
margin-top: 5px;
background-color: #5a6b84;
color: white;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
font-family: 'Noto Sans', sans-serif;
/* font-weight: bold; */
position: relative;
}
#edit-button:hover {
background: #7489a9;
}
::-webkit-scrollbar {
width: 5px; /* Width of the scrollbar */
height: 5px;
}
/* * {
outline: 1px solid rgb(255, 255, 255);
} */
/* Track */
::-webkit-scrollbar-track {
background: #f1f1f1; /* Background of the scrollbar track */
border-radius: 10px;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #2d2d2d; /* Color of the scrollbar thumb */
border-radius: 10px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #000000; /* Color of the scrollbar thumb on hover */
}
#copy-icon {
width: 20px;
height: 20px;
margin-right: 5px;
}
#reference-expand-button{
background-color: #000000;
color: #dcdfe4; /* White text */
font-family: 'Noto Sans', sans-serif;
border-radius: 5px;
font-size: 16px;
border: none;
padding: 5px;
margin: 5px;
}
#linker-button{
background-color: #fee285;
color: black; /* White text */
font-family: 'Noto Sans', sans-serif;
border-radius: 5px;
font-size: 16px;
border: none;
padding: 5px;
margin: 5px;
}
#linker-button:hover {
background-color: #f9f5de;
}
div.collapsed {
display: none;
}
div.expanded {
display: block;
}
/* CSS for the container */
.logo-container {
/* display: flex; Use flexbox to align items */
align-items: center; /* Align items vertically */
justify-content: flex;
padding: 10px; /* Add padding to the container */
}
/* CSS for the image */
img {
width: 100px; /* Set the desired width */
height: auto; /* Allows the image to scale proportionally */
}
.clipboardText {
font-size: 14px;
font-family: 'Noto Sans', sans-serif;
background-color: #475569;
color: #dcdfe4;
padding: 10px;
border-radius: 5px;
width: 100%;
word-wrap: break-word;
}
.reference-button {
background-color: #dde5f0;
color: #dcdfe4;
font-family: 'Noto Sans', sans-serif;
border-radius: 5px;
font-size: 16px;
border: none;
padding: 5px;
margin: 5px;
}
#chat-body {
width: 100%;
font-family: 'Noto Sans', sans-serif;
word-wrap: break-word;
line-height: 20px;
}
b {
font-size: 14px;
font-family: 'Noto Sans', sans-serif;
}
h1 {
font-size: 20px;
font-family: 'Noto Sans', sans-serif;
text-align: center;
}
body {
background-color: #ffffff;
font-family:'Noto Sans', sans-serif;
font-weight: 400;
scrollbar-width: 5px; /* "auto" or "thin" */
scrollbar-color: white white;/* thumb color and track color */
height: 300px;
overflow: scroll;
}
#chat-body-wrapper {
padding-left: 10px;
padding-right: 10px;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
#loading-dots span {
display: inline-block;
animation: bounce 0.9s infinite alternate;
}
.dot1 {
animation-delay: 0.1s;
}
.dot2 {
animation-delay: 0.2s;
}
.dot3 {
animation-delay: 0.3s;
}
</style>
</head>
<body>
<div id="title-bar">Khoj (Esc to Quit)</div>
<div id="chat-body-wrapper">
<div id="input-container">
<textarea id="styled-input" name="styled-input">Hello World!</textarea>
<script>
try {
if (!window.clipboardAPI) {
throw new Error('clipboardAPI is not available');
}
window.clipboardAPI.sendClipboardText((clipboardText) => {
try {
const styledInput = document.getElementById('styled-input');
if (!styledInput) {
throw new Error('styled-input element not found');
}
styledInput.value = clipboardText;
console.log("success: ", clipboardText);
} catch (error) {
console.error('Error handling clipboard text:', error);
}
});
} catch (error) {
console.error('Error setting up clipboard listener:', error);
}
</script>
<div style="display: flex;">
<button id="send-button" onclick="chat()">
Send
<svg style="margin-left: 3px" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-send">
<path d="M5 12l10 0-5-5m5 5-5 5" />
</svg>
</button>
<button id="edit-button" onclick="edit()">
Edit
<svg style="margin-left: 6px; margin-right: 6px;" fill="#fff" height="11px" width="11px" version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" xmlns:xlink="http://www.w3.org/1999/xlink" enable-background="new 0 0 512 512">
<g>
<g>
<path d="m455.1,137.9l-32.4,32.4-81-81.1 32.4-32.4c6.6-6.6 18.1-6.6 24.7,0l56.3,56.4c6.8,6.8 6.8,17.9 0,24.7zm-270.7,271l-81-81.1 209.4-209.7 81,81.1-209.4,209.7zm-99.7-42l60.6,60.7-84.4,23.8 23.8-84.5zm399.3-282.6l-56.3-56.4c-11-11-50.7-31.8-82.4,0l-285.3,285.5c-2.5,2.5-4.3,5.5-5.2,8.9l-43,153.1c-2,7.1 0.1,14.7 5.2,20 5.2,5.3 15.6,6.2 20,5.2l153-43.1c3.4-0.9 6.4-2.7 8.9-5.2l285.1-285.5c22.7-22.7 22.7-59.7 0-82.5z"/>
</g>
</g>
</svg>
</button>
</div>
</div>
<div id="loading-dots" style="display: none;"></div>
<div id="chat-body"></div>
</div>
<script src="main.js"></script>
<script type="text/javascript" src="./assets/purify.min.js?v={{ khoj_version }}"></script>
<script type="text/javascript" src="./assets/markdown-it.min.js"></script>
<script src="./utils.js"></script>
<script src="./chatutils.js"></script>
<script>
let region = null;
let city = null;
let countryName = null;
let timezone = null;
fetch("https://ipapi.co/json")
.then(response => response.json())
.then(data => {
region = data.region;
city = data.city;
countryName = data.country_name;
timezone = data.timezone;
})
.catch(err => {
console.log(err);
return;
});
function toggleLoading() {
var dots = document.getElementById('loading-dots');
if (dots.style.display === 'none') {
dots.innerHTML = 'Loading<span class="dot1">.</span><span class="dot2">.</span><span class="dot3">.</span>';
dots.style.display = 'inline-block';
} else {
dots.innerHTML = '';
dots.style.display = 'none';
}
}
function edit() {
//enable input for text area
let inp = document.getElementById("styled-input");
inp.removeAttribute('readonly');
//put focus on text area
inp.focus();
}
async function chat() {
//set chat body to empty
let chatBody = document.getElementById("chat-body");
chatBody.innerHTML = "";
toggleLoading();
let inp = document.getElementById("styled-input");
query = inp.value;
inp.setAttribute('readonly', true);
let resultsCount = localStorage.getItem("khojResultsCount") || 5;
console.log(`Query: ${query}`);
// Short circuit on empty query
if (query.length === 0)
return;
let chat_body = document.getElementById("chat-body");
let conversationID = chat_body.dataset.conversationId;
let hostURL = await window.hostURLAPI.getURL();
const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` };
if (!conversationID) {
let response = await fetch(`${hostURL}/api/chat/sessions`, { method: "POST", headers });
let data = await response.json();
conversationID = data.conversation_id;
chat_body.dataset.conversationId = conversationID;
}
// Generate backend API URL to execute query
let chatApi = `${hostURL}/api/chat?q=${encodeURIComponent(query)}&n=${resultsCount}&client=web&stream=true&conversation_id=${conversationID}&region=${region}&city=${city}&country=${countryName}&timezone=${timezone}`;
let newResponseEl = document.createElement("div");
newResponseEl.classList.add("chat-message", "khoj");
newResponseEl.attributes["data-meta"] = "🏮 Khoj at " + formatDate(new Date());
chat_body.appendChild(newResponseEl);
let newResponseTextEl = document.createElement("div");
newResponseTextEl.classList.add("chat-message-text", "khoj");
newResponseEl.appendChild(newResponseTextEl);
// Temporary status message to indicate that Khoj is thinking
let loadingEllipsis = document.createElement("div");
loadingEllipsis.classList.add("lds-ellipsis");
let firstEllipsis = document.createElement("div");
firstEllipsis.classList.add("lds-ellipsis-item");
let secondEllipsis = document.createElement("div");
secondEllipsis.classList.add("lds-ellipsis-item");
let thirdEllipsis = document.createElement("div");
thirdEllipsis.classList.add("lds-ellipsis-item");
let fourthEllipsis = document.createElement("div");
fourthEllipsis.classList.add("lds-ellipsis-item");
loadingEllipsis.appendChild(firstEllipsis);
loadingEllipsis.appendChild(secondEllipsis);
loadingEllipsis.appendChild(thirdEllipsis);
loadingEllipsis.appendChild(fourthEllipsis);
newResponseTextEl.appendChild(loadingEllipsis);
document.body.scrollTop = document.getElementById("chat-body").scrollHeight;
// Call Khoj chat API
let response = await fetch(chatApi, { headers });
let rawResponse = "";
let references = null;
const contentType = response.headers.get("content-type");
toggleLoading();
if (contentType === "application/json") {
// Handle JSON response
try {
const responseAsJson = await response.json();
if (responseAsJson.image) {
// If response has image field, response is a generated image.
if (responseAsJson.intentType === "text-to-image") {
rawResponse += `![${query}](data:image/png;base64,${responseAsJson.image})`;
} else if (responseAsJson.intentType === "text-to-image2") {
rawResponse += `![${query}](${responseAsJson.image})`;
} else if (responseAsJson.intentType === "text-to-image-v3") {
rawResponse += `![${query}](data:image/webp;base64,${responseAsJson.image})`;
}
const inferredQueries = responseAsJson.inferredQueries?.[0];
if (inferredQueries) {
rawResponse += `\n\n**Inferred Query**:\n\n${inferredQueries}`;
}
}
if (responseAsJson.context) {
const rawReferenceAsJson = responseAsJson.context;
references = createReferenceSection(rawReferenceAsJson, createLinkerSection=true);
}
if (responseAsJson.detail) {
// If response has detail field, response is an error message.
rawResponse += responseAsJson.detail;
}
} catch (error) {
// If the chunk is not a JSON object, just display it as is
rawResponse += chunk;
} finally {
newResponseTextEl.innerHTML = "";
newResponseTextEl.appendChild(formatHTMLMessage(rawResponse));
if (references != null) {
newResponseTextEl.appendChild(references);
}
document.body.scrollTop = document.getElementById("chat-body").scrollHeight;
}
} else {
// Handle streamed response of type text/event-stream or text/plain
const reader = response.body.getReader();
const decoder = new TextDecoder();
let references = {};
readStream();
function readStream() {
reader.read().then(({ done, value }) => {
if (done) {
// Append any references after all the data has been streamed
if (references != {}) {
newResponseTextEl.appendChild(createReferenceSection(references, createLinkerSection=true));
}
document.body.scrollTop = document.getElementById("chat-body").scrollHeight;
return;
}
// Decode message chunk from stream
const chunk = decoder.decode(value, { stream: true });
if (chunk.includes("### compiled references:")) {
const additionalResponse = chunk.split("### compiled references:")[0];
rawResponse += additionalResponse;
newResponseTextEl.innerHTML = "";
newResponseTextEl.appendChild(formatHTMLMessage(rawResponse));
const rawReference = chunk.split("### compiled references:")[1];
const rawReferenceAsJson = JSON.parse(rawReference);
if (rawReferenceAsJson instanceof Array) {
references["notes"] = rawReferenceAsJson;
} else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) {
references["online"] = rawReferenceAsJson;
}
readStream();
} else {
// Display response from Khoj
if (newResponseTextEl.getElementsByClassName("lds-ellipsis").length > 0) {
newResponseTextEl.removeChild(loadingEllipsis);
}
// If the chunk is not a JSON object, just display it as is
rawResponse += chunk;
newResponseTextEl.innerHTML = "";
newResponseTextEl.appendChild(formatHTMLMessage(rawResponse));
readStream();
}
// Scroll to bottom of chat window as chat response is streamed
document.body.scrollTop = document.getElementById("chat-body").scrollHeight;
});
}
}
document.body.scrollTop = document.getElementById("chat-body").scrollHeight;
}
</script>
</body>
</html>