Add support for multiple chat sessions in the desktop application (#639)

* Add chat sessions to the desktop application
* Increase width of the main chat body to 90vw
* Update the version of electron
* Render the default message if chat history fails to load
* Merge conversation migrations and fix slug setting
* Update the welcome message, use the hostURL, and update background color for chat actions
* Only update the window's web contents if the page is config
This commit is contained in:
sabaimran
2024-02-11 02:35:28 -08:00
committed by GitHub
parent 1412ed6a00
commit 69344a6aa6
4 changed files with 531 additions and 56 deletions

View File

@@ -140,6 +140,9 @@
// Scroll to bottom of chat-body element // Scroll to bottom of chat-body element
chatBody.scrollTop = chatBody.scrollHeight; chatBody.scrollTop = chatBody.scrollHeight;
let chatBodyWrapper = document.getElementById("chat-body-wrapper");
chatBodyWrapperHeight = chatBodyWrapper.clientHeight;
} }
function processOnlineReferences(referenceSection, onlineContext) { function processOnlineReferences(referenceSection, onlineContext) {
@@ -319,14 +322,25 @@
autoResize(); autoResize();
document.getElementById("chat-input").setAttribute("disabled", "disabled"); document.getElementById("chat-input").setAttribute("disabled", "disabled");
let chat_body = document.getElementById("chat-body");
let conversationID = chat_body.dataset.conversationId;
let hostURL = await window.hostURLAPI.getURL(); let hostURL = await window.hostURLAPI.getURL();
if (!conversationID) {
let response = await fetch(`${hostURL}/api/chat/sessions`, { method: "POST" });
let data = await response.json();
conversationID = data.conversation_id;
chat_body.dataset.conversationId = conversationID;
}
// Generate backend API URL to execute query // Generate backend API URL to execute query
let url = `${hostURL}/api/chat?q=${encodeURIComponent(query)}&n=${resultsCount}&client=web&stream=true`; let url = `${hostURL}/api/chat?q=${encodeURIComponent(query)}&n=${resultsCount}&client=web&stream=true&conversation_id=${conversationID}`;
const khojToken = await window.tokenAPI.getToken(); const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` }; const headers = { 'Authorization': `Bearer ${khojToken}` };
let chat_body = document.getElementById("chat-body");
let new_response = document.createElement("div"); let new_response = document.createElement("div");
new_response.classList.add("chat-message", "khoj"); new_response.classList.add("chat-message", "khoj");
new_response.attributes["data-meta"] = "🏮 Khoj at " + formatDate(new Date()); new_response.attributes["data-meta"] = "🏮 Khoj at " + formatDate(new Date());
@@ -559,7 +573,14 @@
const khojToken = await window.tokenAPI.getToken(); const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` }; const headers = { 'Authorization': `Bearer ${khojToken}` };
fetch(`${hostURL}/api/chat/history?client=desktop`, { headers }) let chatBody = document.getElementById("chat-body");
let conversationId = chatBody.dataset.conversationId;
let chatHistoryUrl = `/api/chat/history?client=desktop`;
if (conversationId) {
chatHistoryUrl += `&conversation_id=${conversationId}`;
}
fetch(`${hostURL}${chatHistoryUrl}`, { headers })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.detail) { if (data.detail) {
@@ -584,16 +605,185 @@
return data.response; return data.response;
}) })
.then(response => { .then(response => {
conversationId = response.conversation_id;
const conversationTitle = response.slug || `New conversation 🌱`;
let chatBody = document.getElementById("chat-body");
chatBody.dataset.conversationId = conversationId;
chatBody.dataset.conversationTitle = conversationTitle;
const fullChatLog = response.chat || [];
const fullChatLog = response.chat;
// Render conversation history, if any
fullChatLog.forEach(chat_log => { fullChatLog.forEach(chat_log => {
renderMessageWithReference(chat_log.message, chat_log.by, chat_log.context, new Date(chat_log.created), chat_log.onlineContext, chat_log.intent?.type, chat_log.intent?.["inferred-queries"]); if (chat_log.message != null) {
renderMessageWithReference(
chat_log.message,
chat_log.by,
chat_log.context,
new Date(chat_log.created),
chat_log.onlineContext,
chat_log.intent?.type,
chat_log.intent?.["inferred-queries"]);
}
})
let chatBodyWrapper = document.getElementById("chat-body-wrapper");
chatBodyWrapperHeight = chatBodyWrapper.clientHeight;
chatBody.style.height = chatBodyWrapperHeight;
})
.catch(err => {
// If the server returns a 500 error with detail, render a setup hint.
first_run_message = `Hi 👋🏾, to get started:
<ol>
<li>Generate an API token in the <a class='inline-chat-link' href="#" onclick="window.navigateAPI.navigateToWebSettings()">Khoj Web settings</a></li>
<li>Paste it into the API Key field in the <a class='inline-chat-link' href="#" onclick="window.navigateAPI.navigateToSettings()">Khoj Desktop settings</a></li>
</ol>`
.trim()
.replace(/(\r\n|\n|\r)/gm, "");
renderMessage(first_run_message, "khoj", null, null, true);
// Disable chat input field and update placeholder text
document.getElementById("chat-input").setAttribute("disabled", "disabled");
document.getElementById("chat-input").setAttribute("placeholder", "Configure Khoj to enable chat");
return;
}); });
fetch(`${hostURL}/api/chat/sessions`, { method: "GET", headers })
.then(response => response.json())
.then(data => {
let conversationListBody = document.getElementById("conversation-list-body");
conversationListBody.innerHTML = "";
let conversationListBodyHeader = document.getElementById("conversation-list-header");
let chatBody = document.getElementById("chat-body");
conversationId = chatBody.dataset.conversationId;
if (data.length > 0) {
conversationListBodyHeader.style.display = "block";
for (let index in data) {
let conversation = data[index];
let conversationButton = document.createElement('div');
let incomingConversationId = conversation["conversation_id"];
const conversationTitle = conversation["slug"] || `New conversation 🌱`;
conversationButton.innerHTML = conversationTitle;
conversationButton.classList.add("conversation-button");
if (incomingConversationId == conversationId) {
conversationButton.classList.add("selected-conversation");
}
conversationButton.addEventListener('click', function() {
let chatBody = document.getElementById("chat-body");
chatBody.innerHTML = "";
chatBody.dataset.conversationId = incomingConversationId;
chatBody.dataset.conversationTitle = conversationTitle;
loadChat();
});
let threeDotMenu = document.createElement('div');
threeDotMenu.classList.add("three-dot-menu");
let threeDotMenuButton = document.createElement('button');
threeDotMenuButton.innerHTML = "⋮";
threeDotMenuButton.classList.add("three-dot-menu-button");
threeDotMenuButton.addEventListener('click', function(event) {
event.stopPropagation();
let existingChildren = threeDotMenu.children;
if (existingChildren.length > 1) {
// Skip deleting the first, since that's the menu button.
for (let i = 1; i < existingChildren.length; i++) {
existingChildren[i].remove();
}
return;
}
let conversationMenu = document.createElement('div');
conversationMenu.classList.add("conversation-menu");
let deleteButton = document.createElement('button');
deleteButton.innerHTML = "Delete";
deleteButton.classList.add("delete-conversation-button");
deleteButton.classList.add("three-dot-menu-button-item");
deleteButton.addEventListener('click', function() {
let deleteURL = `/api/chat/history?client=web&conversation_id=${incomingConversationId}`;
fetch(`${hostURL}${deleteURL}` , { method: "DELETE", headers })
.then(response => response.ok ? response.json() : Promise.reject(response))
.then(data => {
let chatBody = document.getElementById("chat-body");
chatBody.innerHTML = "";
chatBody.dataset.conversationId = "";
chatBody.dataset.conversationTitle = "";
loadChat();
}) })
.catch(err => { .catch(err => {
return; return;
}); });
});
conversationMenu.appendChild(deleteButton);
threeDotMenu.appendChild(conversationMenu);
let editTitleButton = document.createElement('button');
editTitleButton.innerHTML = "Rename";
editTitleButton.classList.add("edit-title-button");
editTitleButton.classList.add("three-dot-menu-button-item");
editTitleButton.addEventListener('click', function(event) {
event.stopPropagation();
let conversationMenuChildren = conversationMenu.children;
let totalItems = conversationMenuChildren.length;
for (let i = totalItems - 1; i >= 0; i--) {
conversationMenuChildren[i].remove();
}
// Create a dialog box to get new title for conversation
let conversationTitleInputBox = document.createElement('div');
conversationTitleInputBox.classList.add("conversation-title-input-box");
let conversationTitleInput = document.createElement('input');
conversationTitleInput.classList.add("conversation-title-input");
conversationTitleInput.value = conversationTitle;
conversationTitleInput.addEventListener('click', function(event) {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
conversationTitleInputButton.click();
}
});
conversationTitleInputBox.appendChild(conversationTitleInput);
let conversationTitleInputButton = document.createElement('button');
conversationTitleInputButton.innerHTML = "Save";
conversationTitleInputButton.classList.add("three-dot-menu-button-item");
conversationTitleInputButton.addEventListener('click', function(event) {
event.stopPropagation();
let newTitle = conversationTitleInput.value;
if (newTitle != null) {
let editURL = `/api/chat/title?client=web&conversation_id=${incomingConversationId}&title=${newTitle}`;
fetch(`${hostURL}${editURL}` , { method: "PATCH" })
.then(response => response.ok ? response.json() : Promise.reject(response))
.then(data => {
conversationButton.innerHTML = newTitle;
})
.catch(err => {
return;
});
conversationTitleInputBox.remove();
}});
conversationTitleInputBox.appendChild(conversationTitleInputButton);
conversationMenu.appendChild(conversationTitleInputBox);
});
conversationMenu.appendChild(editTitleButton);
threeDotMenu.appendChild(conversationMenu);
});
threeDotMenu.appendChild(threeDotMenuButton);
conversationButton.appendChild(threeDotMenu);
conversationListBody.appendChild(conversationButton);
}
}
})
fetch(`${hostURL}/api/chat/starters?client=desktop`, { headers }) fetch(`${hostURL}/api/chat/starters?client=desktop`, { headers })
.then(response => response.json()) .then(response => response.json())
@@ -652,19 +842,36 @@
}, 2000); }, 2000);
} }
function createNewConversation() {
let chatBody = document.getElementById("chat-body");
chatBody.innerHTML = "";
flashStatusInChatInput("📝 New conversation started");
chatBody.dataset.conversationId = "";
chatBody.dataset.conversationTitle = "";
renderMessage("Hey 👋🏾, what's up?", "khoj");
}
async function clearConversationHistory() { async function clearConversationHistory() {
let chatInput = document.getElementById("chat-input"); let chatInput = document.getElementById("chat-input");
let originalPlaceholder = chatInput.placeholder; let originalPlaceholder = chatInput.placeholder;
let chatBody = document.getElementById("chat-body"); let chatBody = document.getElementById("chat-body");
let conversationId = chatBody.dataset.conversationId;
let deleteURL = `/api/chat/history?client=desktop`;
if (conversationId) {
deleteURL += `&conversation_id=${conversationId}`;
}
const hostURL = await window.hostURLAPI.getURL(); const hostURL = await window.hostURLAPI.getURL();
const khojToken = await window.tokenAPI.getToken(); const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` }; const headers = { 'Authorization': `Bearer ${khojToken}` };
fetch(`${hostURL}/api/chat/history?client=desktop`, { method: "DELETE", headers }) fetch(`${hostURL}${deleteURL}`, { method: "DELETE", headers })
.then(response => response.ok ? response.json() : Promise.reject(response)) .then(response => response.ok ? response.json() : Promise.reject(response))
.then(data => { .then(data => {
chatBody.innerHTML = ""; chatBody.innerHTML = "";
chatBody.dataset.conversationId = "";
chatBody.dataset.conversationTitle = "";
loadChat(); loadChat();
flashStatusInChatInput("🗑 Cleared conversation history"); flashStatusInChatInput("🗑 Cleared conversation history");
}) })
@@ -776,6 +983,14 @@
// Stop the countdown timer UI // Stop the countdown timer UI
document.getElementById('countdown-circle').style.animation = "none"; document.getElementById('countdown-circle').style.animation = "none";
}; };
function handleCollapseSidePanel() {
document.getElementById('side-panel').classList.toggle('collapsed');
document.getElementById('new-conversation').classList.toggle('collapsed');
document.getElementById('existing-conversations').classList.toggle('collapsed');
document.getElementById('chat-section-wrapper').classList.toggle('mobile-friendly');
}
</script> </script>
<body> <body>
<div id="khoj-empty-container" class="khoj-empty-container"> <div id="khoj-empty-container" class="khoj-empty-container">
@@ -793,6 +1008,37 @@
</nav> </nav>
</div> </div>
<div id="chat-section-wrapper">
<div id="side-panel-wrapper">
<div id="side-panel">
<div id="new-conversation">
<button class="side-panel-button" id="new-conversation-button" onclick="createNewConversation()">
New Topic
<svg class="new-convo-button" viewBox="0 0 35 35" fill="#000000" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0c-8.836 0-16 7.163-16 16s7.163 16 16 16c8.837 0 16-7.163 16-16s-7.163-16-16-16zM16 30.032c-7.72 0-14-6.312-14-14.032s6.28-14 14-14 14 6.28 14 14-6.28 14.032-14 14.032zM23 15h-6v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v6h-6c-0.552 0-1 0.448-1 1s0.448 1 1 1h6v6c0 0.552 0.448 1 1 1s1-0.448 1-1v-6h6c0.552 0 1-0.448 1-1s-0.448-1-1-1z"></path>
</svg>
</button>
</div>
<div id="existing-conversations">
<div id="conversation-list">
<div id="conversation-list-header" style="display: none;">Recent Conversations</div>
<div id="conversation-list-body"></div>
</div>
</div>
</div>
<div id="collapse-side-panel">
<button
class="side-panel-button"
id="collapse-side-panel-button"
onclick="handleCollapseSidePanel()"
>
<svg class="side-panel-collapse" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.82054 20.7313C8.21107 21.1218 8.84423 21.1218 9.23476 20.7313L15.8792 14.0868C17.0505 12.9155 17.0508 11.0167 15.88 9.84497L9.3097 3.26958C8.91918 2.87905 8.28601 2.87905 7.89549 3.26958C7.50497 3.6601 7.50497 4.29327 7.89549 4.68379L14.4675 11.2558C14.8581 11.6464 14.8581 12.2795 14.4675 12.67L7.82054 19.317C7.43002 19.7076 7.43002 20.3407 7.82054 20.7313Z" fill="#0F0F0F"/>
</svg>
</button>
</div>
</div>
<div id="chat-body-wrapper">
<!-- Chat Body --> <!-- Chat Body -->
<div id="chat-body"></div> <div id="chat-body"></div>
@@ -836,6 +1082,8 @@
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
</body> </body>
<style> <style>
@@ -855,15 +1103,86 @@
font-weight: 300; font-weight: 300;
line-height: 1.5em; line-height: 1.5em;
} }
body > * { body > * {
padding: 10px; padding: 10px;
margin: 10px; margin: 10px;
} }
input.conversation-title-input {
font-family: var(--font-family);
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
padding: 5px;
border: 1px solid var(--main-text-color);
border-radius: 5px;
margin: 4px;
}
input.conversation-title-input:focus {
outline: none;
}
#chat-section-wrapper {
display: grid;
grid-template-columns: auto auto;
grid-column-gap: 10px;
grid-row-gap: 10px;
padding: 10px;
margin: 10px;
overflow-y: scroll;
}
#chat-section-wrapper.mobile-friendly {
grid-template-columns: auto auto;
}
#chat-body-wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
}
#side-panel {
padding: 10px;
background: var(--background-color);
border-radius: 5px;
box-shadow: 0 0 11px #aaa;
overflow-y: scroll;
text-align: left;
transition: width 0.3s ease-in-out;
width: 250px;
}
div#side-panel.collapsed {
width: 1px;
display: block;
overflow: hidden;
}
div#collapse-side-panel {
align-self: center;
padding: 8px;
}
div#conversation-list-body {
display: grid;
grid-template-columns: 1fr;
grid-gap: 8px;
}
div#side-panel-wrapper {
display: flex
}
#chat-body { #chat-body {
font-size: small; font-size: small;
margin: 0px; margin: 0px;
line-height: 20px; line-height: 20px;
overflow-y: scroll; /* Make chat body scroll to see history */ overflow-y: scroll;
overflow-x: hidden;
} }
/* add chat metatdata to bottom of bubble */ /* add chat metatdata to bottom of bubble */
.chat-message::after { .chat-message::after {
@@ -871,7 +1190,7 @@
display: block; display: block;
font-size: x-small; font-size: x-small;
color: #475569; color: #475569;
margin: -8px 4px 0 -5px; margin: -8px 4px 0px 0px;
} }
/* move message by khoj to left */ /* move message by khoj to left */
.chat-message.khoj { .chat-message.khoj {
@@ -962,6 +1281,7 @@
grid-row-gap: 10px; grid-row-gap: 10px;
background: #f9fafc; background: #f9fafc;
align-items: center; align-items: center;
background-color: var(--background-color);
} }
.option:hover { .option:hover {
box-shadow: 0 0 11px #aaa; box-shadow: 0 0 11px #aaa;
@@ -998,9 +1318,33 @@
margin-top: -2px; margin-top: -2px;
margin-left: -5px; margin-left: -5px;
} }
.side-panel-button {
background: var(--background-color);
border: none;
box-shadow: none;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
cursor: pointer;
transition: background 0.3s ease-in-out;
border-radius: 5%;;
font-family: var(--font-family);
padding: 8px;
font-size: large;
}
svg.side-panel-collapse {
width: 30px;
height: 30px;
}
.side-panel-button:hover,
.input-row-button:hover { .input-row-button:hover {
background: var(--primary-hover); background: var(--primary-hover);
} }
.side-panel-button:active,
.input-row-button:active { .input-row-button:active {
background: var(--primary-active); background: var(--primary-active);
} }
@@ -1236,10 +1580,36 @@
#clear-chat-button { #clear-chat-button {
margin-left: 0; margin-left: 0;
} }
div#side-panel.collapsed {
width: 0px;
display: block;
overflow: hidden;
padding: 0;
}
svg.side-panel-collapse {
width: 24px;
height: 24px;
}
#chat-body-wrapper {
min-width: 0;
}
div#chat-section-wrapper {
padding: 4px;
margin: 4px;
grid-column-gap: 4px;
}
div#collapse-side-panel {
align-self: center;
padding: 0px;
}
} }
@media only screen and (min-width: 600px) { @media only screen and (min-width: 600px) {
body { body {
grid-template-columns: auto min(70vw, 100%) auto; grid-template-columns: auto min(90vw, 100%) auto;
grid-template-rows: auto auto minmax(80px, 100%) auto; grid-template-rows: auto auto minmax(80px, 100%) auto;
} }
body > * { body > * {
@@ -1252,6 +1622,110 @@
font-size: medium; font-size: medium;
} }
svg.new-convo-button {
width: 20px;
margin-left: 5px;
}
div#new-conversation {
text-align: left;
border-bottom: 1px solid var(--main-text-color);
margin-bottom: 8px;
}
button#new-conversation-button {
display: inline-flex;
align-items: center;
}
div.conversation-button {
background: var(--background-color);
color: var(--main-text-color);
border: 1px solid var(--main-text-color);
border-radius: 5px;
padding: 5px;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
cursor: pointer;
transition: background 0.2s ease-in-out;
text-align: left;
display: flex;
position: relative;
}
.three-dot-menu {
display: none;
/* background: var(--background-color); */
/* border: 1px solid var(--main-text-color); */
border-radius: 5px;
/* position: relative; */
position: absolute;
right: 4;
top: 4;
}
button.three-dot-menu-button-item {
background: var(--background-color);
color: var(--main-text-color);
border: none;
box-shadow: none;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
cursor: pointer;
transition: background 0.3s ease-in-out;
font-family: var(--font-family);
border-radius: 4px;
right: 0;
}
button.three-dot-menu-button-item:hover {
background: var(--primary-hover);
color: var(--primary-inverse);
}
.three-dot-menu-button {
background: var(--background-color);
border: none;
box-shadow: none;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
cursor: pointer;
transition: background 0.3s ease-in-out;
font-family: var(--font-family);
border-radius: 4px;
right: 0;
}
.conversation-button:hover .three-dot-menu {
display: block;
}
div.conversation-menu {
position: absolute;
z-index: 1;
top: 100%;
right: 0;
text-align: right;
background-color: var(--background-color);
border: 1px solid var(--main-text-color);
border-radius: 5px;
padding: 5px;
box-shadow: 0 0 11px #aaa;
}
div.conversation-button:hover {
background: var(--primary-hover);
color: var(--primary-inverse);
}
div.selected-conversation {
background: var(--primary-hover) !important;
color: var(--primary-inverse) !important;
}
@keyframes gradient { @keyframes gradient {
0% { 0% {
background-position: 0% 50%; background-position: 0% 50%;

View File

@@ -225,7 +225,8 @@ function pushDataToKhoj (regenerate = false) {
.finally(() => { .finally(() => {
// Syncing complete // Syncing complete
syncing = false; syncing = false;
if (win = BrowserWindow.getAllWindows()[0]) { const win = BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('config'));
if (win) {
win.webContents.send('update-state', state); win.webContents.send('update-state', state);
} }
}); });

View File

@@ -10,7 +10,7 @@
"main": "main.js", "main": "main.js",
"private": false, "private": false,
"devDependencies": { "devDependencies": {
"electron": "25.8.4" "electron": "28.2.1"
}, },
"scripts": { "scripts": {
"start": "yarn electron ." "start": "yarn electron ."

View File

@@ -379,10 +379,10 @@ electron-updater@^4.6.1:
lodash.isequal "^4.5.0" lodash.isequal "^4.5.0"
semver "^7.3.5" semver "^7.3.5"
electron@25.8.4: electron@28.2.1:
version "25.8.4" version "28.2.1"
resolved "https://registry.yarnpkg.com/electron/-/electron-25.8.4.tgz#b50877aac7d96323920437baf309ad86382cb455" resolved "https://registry.yarnpkg.com/electron/-/electron-28.2.1.tgz#8edf2be24d97160b7eb52b7ce9a2424cf14c0791"
integrity sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg== integrity sha512-wlzXf+OvOiVlBf9dcSeMMf7Q+N6DG+wtgFbMK0sA/JpIJcdosRbLMQwLg/LTwNVKIbmayqFLDp4FmmFkEMhbYA==
dependencies: dependencies:
"@electron/get" "^2.0.0" "@electron/get" "^2.0.0"
"@types/node" "^18.11.18" "@types/node" "^18.11.18"