diff --git a/src/interface/desktop/assets/icons/chat.svg b/src/interface/desktop/assets/icons/chat.svg new file mode 100644 index 00000000..a5cbf3af --- /dev/null +++ b/src/interface/desktop/assets/icons/chat.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/src/interface/desktop/assets/icons/copy-button-success.svg b/src/interface/desktop/assets/icons/copy-button-success.svg new file mode 100644 index 00000000..7a00dcd0 --- /dev/null +++ b/src/interface/desktop/assets/icons/copy-button-success.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/interface/desktop/assets/icons/copy-button.svg b/src/interface/desktop/assets/icons/copy-button.svg new file mode 100644 index 00000000..36fc8971 --- /dev/null +++ b/src/interface/desktop/assets/icons/copy-button.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/desktop/assets/icons/search.svg b/src/interface/desktop/assets/icons/search.svg new file mode 100644 index 00000000..f0e463f2 --- /dev/null +++ b/src/interface/desktop/assets/icons/search.svg @@ -0,0 +1,25 @@ + + + + + diff --git a/src/interface/desktop/assets/khoj.css b/src/interface/desktop/assets/khoj.css index c2a7d367..ec65ec56 100644 --- a/src/interface/desktop/assets/khoj.css +++ b/src/interface/desktop/assets/khoj.css @@ -71,14 +71,19 @@ div.khoj-header { gap: 20px; padding: 24px 16px 0px 0px; margin: 0 0 16px 0; + align-items: center; + user-select: none; -webkit-user-select: none; -webkit-app-region: drag; } +/* Keeps the navigation menu clickable */ a.khoj-nav { -webkit-app-region: no-drag; } - +div.khoj-nav { + -webkit-app-region: no-drag; +} nav.khoj-nav { display: grid; grid-auto-flow: column; @@ -112,12 +117,106 @@ a.khoj-logo { a.khoj-nav-selected { background-color: var(--primary); } +.nav-icon { + width: 24px; + height: 24px; +} +#agents-icon { + width: 30px; + height: 30px; +} +span.khoj-nav-item-text { + padding-top: 6px; + padding-left: 8px; +} img.khoj-logo { width: min(60vw, 90px); max-width: 100%; justify-self: center; } +/* Dropdown in navigation menu*/ +#khoj-nav-menu-container { + display: flex; + align-items: center; +} +.khoj-nav-dropdown-content { + display: block; + grid-auto-flow: row; + position: absolute; + background-color: var(--background-color); + min-width: 160px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + right: 5vw; + top: 64px; + z-index: 1; + opacity: 0; + transition: opacity 0.1s ease-in-out; + pointer-events: none; + text-align: left; +} +.khoj-nav-dropdown-content.show { + opacity: 1; + pointer-events: auto; +} +.khoj-nav-dropdown-content a { + color: black; + padding: 12px 16px; + text-decoration: none; + display: block; +} +.khoj-nav-dropdown-content a:hover { + background-color: var(--primary-hover); +} +.khoj-nav-username { + padding: 12px 16px; + text-decoration: none; + display: block; + font-weight: bold; +} +.circle { + border-radius: 50%; + border: 3px dotted var(--main-text-color); + width: 32px; + height: 32px; + padding: 3px; + cursor: pointer; +} +.circle:hover { + background-color: var(--primary-hover); +} +.user-initial { + background-color: var(--background-color); + color: black; + display: grid; + justify-content: center; + align-items: center; + font-size: 20px; + box-sizing: unset; + width: 40px; + height: 35px; + padding-top: 8px; +} +.subscribed { + border: 3px solid var(--primary-hover); +} + +@media screen and (max-width: 600px) { + .khoj-nav-dropdown-content { + display: block; + grid-auto-flow: row; + position: absolute; + background-color: var(--background-color); + min-width: 160px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + right: 10px; + z-index: 1; + opacity: 0; + transition: opacity 0.1s ease-in-out; + pointer-events: none; + } +} + @media only screen and (max-width: 600px) { div.khoj-header { display: grid; @@ -131,4 +230,10 @@ img.khoj-logo { grid-gap: 0px; justify-content: space-between; } + a.khoj-nav { + padding: 0 16px; + } + span.khoj-nav-item-text { + display: none; + } } diff --git a/src/interface/desktop/chat.html b/src/interface/desktop/chat.html index f37ae562..92554e13 100644 --- a/src/interface/desktop/chat.html +++ b/src/interface/desktop/chat.html @@ -13,21 +13,22 @@ -
-
+
-
- - -
+
- @@ -1195,7 +1222,7 @@ #chat-section-wrapper { display: grid; - grid-template-columns: auto auto; + grid-template-columns: auto 1fr; grid-column-gap: 10px; grid-row-gap: 10px; padding: 10px; @@ -1203,10 +1230,6 @@ overflow-y: scroll; } - #chat-section-wrapper.mobile-friendly { - grid-template-columns: auto auto; - } - #chat-body-wrapper { display: flex; flex-direction: column; @@ -1411,8 +1434,6 @@ transition: background 0.3s ease-in-out; border-radius: 5%;; font-family: var(--font-family); - padding: 8px; - font-size: large; } svg#side-panel-collapse { @@ -1619,8 +1640,13 @@ transition: 0.5s; } + img.copy-icon { + width: 16px; + height: 16px; + } + button.copy-button:hover { - background-color: black; + background-color: var(--primary-hover); color: #f5f5f5; } @@ -1723,14 +1749,18 @@ } div#new-conversation { + display: grid; + grid-auto-flow: column; + font-size: large; text-align: left; border-bottom: 1px solid var(--main-text-color); - margin-bottom: 8px; + margin: 8px 0; } button#new-conversation-button { display: inline-flex; align-items: center; + justify-self: end; } div.conversation-button { @@ -1856,6 +1886,44 @@ white-space: pre-wrap; } + .loading-spinner { + display: inline-block; + position: relative; + width: 80px; + height: 80px; + } + .loading-spinner div { + position: absolute; + border: 4px solid var(--primary-hover); + opacity: 1; + border-radius: 50%; + animation: lds-ripple 0.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + .loading-spinner div:nth-child(2) { + animation-delay: -0.5s; + } + @keyframes lds-ripple { + 0% { + top: 36px; + left: 36px; + width: 0; + height: 0; + opacity: 1; + border-color: var(--primary-hover); + } + 50% { + border-color: var(--flower); + } + 100% { + top: 0px; + left: 0px; + width: 72px; + height: 72px; + opacity: 0; + border-color: var(--water); + } + } + .lds-ellipsis { display: inline-block; position: relative; @@ -1865,8 +1933,8 @@ .lds-ellipsis div { position: absolute; top: 12px; - width: 12px; - height: 12px; + width: 8px; + height: 8px; border-radius: 50%; background: var(--main-text-color); animation-timing-function: cubic-bezier(0, 1, 1, 0); diff --git a/src/interface/desktop/config.html b/src/interface/desktop/config.html index 4fbea24b..d6a3e702 100644 --- a/src/interface/desktop/config.html +++ b/src/interface/desktop/config.html @@ -9,19 +9,20 @@ + - +
diff --git a/src/interface/desktop/main.js b/src/interface/desktop/main.js index d561a2d5..1610a5e7 100644 --- a/src/interface/desktop/main.js +++ b/src/interface/desktop/main.js @@ -351,15 +351,28 @@ async function deleteAllFiles () { } } +// Fetch user info from Khoj server +async function getUserInfo() { + const getUserInfoURL = `${store.get('hostURL') || KHOJ_URL}/api/v1/user?client=desktop`; + const headers = { 'Authorization': `Bearer ${store.get('khojToken')}` }; + try { + let response = await axios.get(getUserInfoURL, { headers }); + return response.data; + } catch (err) { + console.error(err); + } +} let firstRun = true; let win = null; +let titleBarStyle = process.platform === 'win32' ? 'default' : 'hidden'; const createWindow = (tab = 'chat.html') => { win = new BrowserWindow({ width: 800, height: 800, show: false, - titleBarStyle: 'hidden', + titleBarStyle: titleBarStyle, + autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: true, @@ -464,13 +477,15 @@ app.whenReady().then(() => { ipcMain.handle('setToken', setToken); ipcMain.handle('getToken', getToken); + ipcMain.handle('getUserInfo', getUserInfo); ipcMain.handle('syncData', (event, regenerate) => { syncData(regenerate); }); ipcMain.handle('deleteAllFiles', deleteAllFiles); - createWindow() + createWindow(); + app.setAboutPanelOptions({ applicationName: "Khoj", @@ -515,7 +530,8 @@ function openAboutWindow() { aboutWindow = new BrowserWindow({ width: 400, height: 400, - titleBarStyle: 'hidden', + titleBarStyle: titleBarStyle, + autoHideMenuBar: true, show: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), diff --git a/src/interface/desktop/preload.js b/src/interface/desktop/preload.js index 8d5152b7..24dc9919 100644 --- a/src/interface/desktop/preload.js +++ b/src/interface/desktop/preload.js @@ -53,6 +53,10 @@ contextBridge.exposeInMainWorld('syncDataAPI', { deleteAllFiles: () => ipcRenderer.invoke('deleteAllFiles') }) +contextBridge.exposeInMainWorld('userInfoAPI', { + getUserInfo: () => ipcRenderer.invoke('getUserInfo') +}) + contextBridge.exposeInMainWorld('tokenAPI', { setToken: (token) => ipcRenderer.invoke('setToken', token), getToken: () => ipcRenderer.invoke('getToken') diff --git a/src/interface/desktop/search.html b/src/interface/desktop/search.html index ffc1bf64..9a3e0e0b 100644 --- a/src/interface/desktop/search.html +++ b/src/interface/desktop/search.html @@ -246,6 +246,16 @@ window.history.pushState({}, "", url.href); } + window.addEventListener("DOMContentLoaded", async() => { + // Setup the header pane + document.getElementById("khoj-header").innerHTML = await populateHeaderPane(); + // Setup the nav menu + document.getElementById("profile-picture").addEventListener("click", toggleNavMenu); + // Set the active nav pane + document.getElementById("search-nav")?.classList.add("khoj-nav-selected"); + }) + + window.addEventListener("load", async function() { // Dynamically populate type dropdown based on enabled content types and type passed as URL query parameter await populate_type_dropdown(); @@ -259,16 +269,7 @@ - +
diff --git a/src/interface/desktop/utils.js b/src/interface/desktop/utils.js index 8f9c0aeb..df05f1c0 100644 --- a/src/interface/desktop/utils.js +++ b/src/interface/desktop/utils.js @@ -24,3 +24,59 @@ window.appInfoAPI.getInfo((_, info) => { khojTitleElement.innerHTML = 'Khoj for ' + (info.platform === 'win32' ? 'Windows' : info.platform === 'darwin' ? 'macOS' : 'Linux') + ''; } }); + +function toggleNavMenu() { + let menu = document.getElementById("khoj-nav-menu"); + menu.classList.toggle("show"); +} + +// Close the dropdown menu if the user clicks outside of it +document.addEventListener('click', function(event) { + let menu = document.getElementById("khoj-nav-menu"); + let menuContainer = document.getElementById("khoj-nav-menu-container"); + let isClickOnMenu = menuContainer.contains(event.target) || menuContainer === event.target; + if (isClickOnMenu === false && menu.classList.contains("show")) { + menu.classList.remove("show"); + } +}); + +async function populateHeaderPane() { + let userInfo = null; + try { + userInfo = await window.userInfoAPI.getUserInfo(); + } catch (error) { + console.log("User not logged in"); + } + + let username = userInfo?.username ?? "?"; + let user_photo = userInfo?.photo; + let is_active = userInfo?.is_active; + let has_documents = userInfo?.has_documents; + + // Populate the header element with the navigation pane + return ` + + + `; +} diff --git a/src/khoj/database/adapters/__init__.py b/src/khoj/database/adapters/__init__.py index 72e2a360..95a75b7d 100644 --- a/src/khoj/database/adapters/__init__.py +++ b/src/khoj/database/adapters/__init__.py @@ -196,6 +196,17 @@ def get_user_name(user: KhojUser): return None +def get_user_photo(user: KhojUser): + full_name = user.get_full_name() + if not is_none_or_empty(full_name): + return full_name + google_profile: GoogleUser = GoogleUser.objects.filter(user=user).first() + if google_profile: + return google_profile.picture + + return None + + def get_user_subscription(email: str) -> Optional[Subscription]: return Subscription.objects.filter(user__email=email).first() @@ -796,7 +807,7 @@ class EntryAdapters: return await Entry.objects.filter(user=user, file_path=file_path).adelete() @staticmethod - def aget_all_filenames_by_source(user: KhojUser, file_source: str): + def get_all_filenames_by_source(user: KhojUser, file_source: str): return ( Entry.objects.filter(user=user, file_source=file_source) .distinct("file_path") diff --git a/src/khoj/interface/web/assets/icons/agents.svg b/src/khoj/interface/web/assets/icons/agents.svg new file mode 100644 index 00000000..f3974293 --- /dev/null +++ b/src/khoj/interface/web/assets/icons/agents.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/khoj/interface/web/assets/icons/chat.svg b/src/khoj/interface/web/assets/icons/chat.svg index ca42280c..a5cbf3af 100644 --- a/src/khoj/interface/web/assets/icons/chat.svg +++ b/src/khoj/interface/web/assets/icons/chat.svg @@ -1,693 +1,24 @@ - - - - - - - - + + + + + diff --git a/src/khoj/interface/web/assets/icons/copy-button-success.svg b/src/khoj/interface/web/assets/icons/copy-button-success.svg new file mode 100644 index 00000000..7a00dcd0 --- /dev/null +++ b/src/khoj/interface/web/assets/icons/copy-button-success.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/khoj/interface/web/assets/icons/copy-button.svg b/src/khoj/interface/web/assets/icons/copy-button.svg new file mode 100644 index 00000000..36fc8971 --- /dev/null +++ b/src/khoj/interface/web/assets/icons/copy-button.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/khoj/interface/web/assets/icons/copy_button.svg b/src/khoj/interface/web/assets/icons/copy_button.svg deleted file mode 100644 index fadf344a..00000000 --- a/src/khoj/interface/web/assets/icons/copy_button.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/khoj/interface/web/assets/icons/search.svg b/src/khoj/interface/web/assets/icons/search.svg new file mode 100644 index 00000000..f0e463f2 --- /dev/null +++ b/src/khoj/interface/web/assets/icons/search.svg @@ -0,0 +1,25 @@ + + + + + diff --git a/src/khoj/interface/web/assets/khoj.css b/src/khoj/interface/web/assets/khoj.css index 3d7e7d4a..3c3536a7 100644 --- a/src/khoj/interface/web/assets/khoj.css +++ b/src/khoj/interface/web/assets/khoj.css @@ -76,6 +76,9 @@ padding: 16px 0; margin: 0; } +.khoj-header { + align-items: center; +} .khoj-footer { margin: 16px 0 0 0; } @@ -112,6 +115,18 @@ a.khoj-logo { .khoj-nav-selected { background-color: var(--primary); } +.nav-icon { + width: 24px; + height: 24px; +} +#agents-icon { + width: 30px; + height: 30px; +} +span.khoj-nav-item-text { + padding-top: 6px; + padding-left: 8px; +} img.khoj-logo { width: min(60vw, 90px); max-width: 100%; @@ -177,7 +192,8 @@ img.khoj-logo { font-size: 20px; box-sizing: unset; width: 40px; - height: 40px; + height: 35px; + padding-top: 8px } .subscribed { border: 3px solid var(--primary-hover); @@ -212,4 +228,10 @@ img.khoj-logo { grid-gap: 0px; justify-content: space-between; } + a.khoj-nav { + padding: 0 16px; + } + span.khoj-nav-item-text { + display: none; + } } diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index e5c0ce1b..87d42fd6 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -30,20 +30,21 @@ To get started, just start typing below. You can also type / to see a list of co const allowedExtensions = ['text/org', 'text/markdown', 'text/plain', 'text/html', 'application/pdf']; const allowedFileEndings = ['org', 'md', 'txt', 'html', 'pdf']; let chatOptions = []; - function copyProgrammaticOutput(event) { - // Remove the first 4 characters which are the "Copy" button + function copyParentText(event) { const button = event.currentTarget; - const programmaticOutput = button.parentNode.textContent.trim(); - navigator.clipboard.writeText(programmaticOutput).then(() => { - button.textContent = "✅ Copied to clipboard!"; + const textContent = button.parentNode.textContent.trim(); + navigator.clipboard.writeText(textContent).then(() => { + button.firstChild.src = "/static/assets/icons/copy-button-success.svg"; setTimeout(() => { - button.textContent = "✅"; + button.firstChild.src = "/static/assets/icons/copy-button.svg"; }, 1000); }).catch((error) => { console.error("Error copying programmatic output to clipboard:", error); - button.textContent = "⛔️ Failed to copy!"; + const originalButtonText = button.innerHTML; + button.innerHTML = "⛔️"; setTimeout(() => { - button.textContent = "⛔️"; + button.innerHTML = originalButtonText; + button.firstChild.src = "/static/assets/icons/copy-button.svg"; }, 1000); }); } @@ -323,7 +324,7 @@ To get started, just start typing below. You can also type / to see a list of co renderMessage(message, by, dt, references); } - function formatHTMLMessage(htmlMessage, raw=false) { + function formatHTMLMessage(htmlMessage, raw=false, willReplace=true) { var md = window.markdownit(); let newHTML = htmlMessage; @@ -348,6 +349,19 @@ To get started, just start typing below. You can also type / to see a list of co element.innerHTML = newHTML; element.className = "chat-message-text-response"; + // Add a copy button to each chat message, if it doesn't already exist + if (willReplace === true) { + let copyButton = document.createElement('button'); + copyButton.classList.add("copy-button"); + copyButton.title = "Copy Message"; + let copyIcon = document.createElement("img"); + copyIcon.src = "/static/assets/icons/copy-button.svg"; + copyIcon.classList.add("copy-icon"); + copyButton.appendChild(copyIcon); + copyButton.addEventListener('click', copyParentText); + element.append(copyButton); + } + // Get any elements with a class that starts with "language" let codeBlockElements = element.querySelectorAll('[class^="language-"]'); // For each element, add a parent div with the class "programmatic-output" @@ -359,15 +373,18 @@ To get started, just start typing below. You can also type / to see a list of co codeElement.parentNode.insertBefore(parentDiv, codeElement); // Move the code element into the parent div parentDiv.appendChild(codeElement); - // Add a copy button to each element - let copyButton = document.createElement('button'); - copyButton.classList.add("copy-button"); - let copyIcon = document.createElement("img"); - copyIcon.src = "/static/assets/icons/copy_button.svg"; - copyIcon.classList.add("copy-icon"); - copyButton.appendChild(copyIcon); - copyButton.addEventListener('click', copyProgrammaticOutput); - codeElement.prepend(copyButton); + // Add a copy button to each code block, if it doesn't already exist + if (willReplace === true) { + let copyButton = document.createElement('button'); + copyButton.classList.add("copy-button"); + copyButton.title = "Copy Code"; + let copyIcon = document.createElement("img"); + copyIcon.src = "/static/assets/icons/copy-button.svg"; + copyIcon.classList.add("copy-icon"); + copyButton.appendChild(copyIcon); + copyButton.addEventListener('click', copyParentText); + codeElement.prepend(copyButton); + } }); // Get all code elements that have no class. @@ -573,7 +590,7 @@ To get started, just start typing below. You can also type / to see a list of co if (replace) { newResponseElement.innerHTML = ""; } - newResponseElement.appendChild(formatHTMLMessage(rawResponse)); + newResponseElement.appendChild(formatHTMLMessage(rawResponse, false, replace)); document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; } @@ -1098,7 +1115,6 @@ To get started, just start typing below. You can also type / to see a list of co agentMetadataElement.style.display = "none"; } - let chatBodyWrapper = document.getElementById("chat-body-wrapper"); const fullChatLog = response.chat || []; fullChatLog.forEach(chat_log => { @@ -1116,6 +1132,7 @@ To get started, just start typing below. You can also type / to see a list of co }); // Add fade out animation to loading screen and remove it after the animation ends + let chatBodyWrapper = document.getElementById("chat-body-wrapper"); chatBodyWrapperHeight = chatBodyWrapper.clientHeight; chatBody.style.height = chatBodyWrapperHeight; setTimeout(() => { @@ -2164,7 +2181,7 @@ To get started, just start typing below. You can also type / to see a list of co } button.copy-button:hover { - background-color: black; + background-color: var(--primary-hover); color: #f5f5f5; } @@ -2411,7 +2428,6 @@ To get started, just start typing below. You can also type / to see a list of co width: 80px; height: 80px; } - .loading-spinner div { position: absolute; border: 4px solid var(--primary-hover); @@ -2419,10 +2435,30 @@ To get started, just start typing below. You can also type / to see a list of co border-radius: 50%; animation: lds-ripple 0.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; } - .loading-spinner div:nth-child(2) { animation-delay: -0.5s; } + @keyframes lds-ripple { + 0% { + top: 36px; + left: 36px; + width: 0; + height: 0; + opacity: 1; + border-color: var(--primary-hover); + } + 50% { + border-color: var(--flower); + } + 100% { + top: 0px; + left: 0px; + width: 72px; + height: 72px; + opacity: 0; + border-color: var(--water); + } + } #agent-metadata-content { display: grid; @@ -2588,28 +2624,6 @@ To get started, just start typing below. You can also type / to see a list of co } - @keyframes lds-ripple { - 0% { - top: 36px; - left: 36px; - width: 0; - height: 0; - opacity: 1; - border-color: var(--primary-hover); - } - 50% { - border-color: var(--flower); - } - 100% { - top: 0px; - left: 0px; - width: 72px; - height: 72px; - opacity: 0; - border-color: var(--water); - } - } - .lds-ellipsis { display: inline-block; position: relative; diff --git a/src/khoj/interface/web/utils.html b/src/khoj/interface/web/utils.html index 8a33be89..82bc6518 100644 --- a/src/khoj/interface/web/utils.html +++ b/src/khoj/interface/web/utils.html @@ -4,9 +4,16 @@