diff --git a/documentation/assets/img/agents_demo.gif b/documentation/assets/img/agents_demo.gif deleted file mode 100644 index 2669033e..00000000 Binary files a/documentation/assets/img/agents_demo.gif and /dev/null differ diff --git a/documentation/assets/img/notion_integration.gif b/documentation/assets/img/notion_integration.gif deleted file mode 100644 index e976963f..00000000 Binary files a/documentation/assets/img/notion_integration.gif and /dev/null differ diff --git a/documentation/docs/clients/desktop.md b/documentation/docs/clients/desktop.md index 4777dda5..cd42fd52 100644 --- a/documentation/docs/clients/desktop.md +++ b/documentation/docs/clients/desktop.md @@ -8,7 +8,7 @@ sidebar_position: 1 Use the Desktop app to chat and search with Khoj. You can also sync any relevant files with Khoj using the app. -Khoj will use these files to provide contextual reponses when you search or chat. +Khoj will use these files to provide contextual responses when you search or chat. ## Features - **Chat** diff --git a/documentation/docs/clients/web.md b/documentation/docs/clients/web.md index dc583d71..0d6def28 100644 --- a/documentation/docs/clients/web.md +++ b/documentation/docs/clients/web.md @@ -25,7 +25,7 @@ You can upload documents to Khoj from the web interface, one at a time. This is 1. You can drag and drop the document into the chat window. 2. Or click the paperclip icon in the chat window and select the document from your file system. -![demo of dragging and dropping a file](https://khoj-web-bucket.s3.amazonaws.com/drag_drop_file.gif) +![demo of dragging and dropping a file](https://assets.khoj.dev/drag_drop_file.gif) ### Install on Phone You can optionally install Khoj as a [Progressive Web App (PWA)](https://web.dev/learn/pwa/installation). This makes it quick and easy to access Khoj on your phone. diff --git a/documentation/docs/contributing/development.mdx b/documentation/docs/contributing/development.mdx index 7045d741..d7ea3ed9 100644 --- a/documentation/docs/contributing/development.mdx +++ b/documentation/docs/contributing/development.mdx @@ -14,7 +14,7 @@ If you're looking for a place to get started, check out the list of [Github Issu ## Local Server Installation ### Using Pip -#### 1. Install +#### 1. Khoj Installation ```mdx-code-block import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -43,7 +43,7 @@ git clone https://github.com/khoj-ai/khoj && cd khoj python3 -m venv .venv && .venv\Scripts\activate # Install Khoj for Development -pip install -e .[dev] +pip install -e '.[dev]' ``` @@ -55,14 +55,59 @@ git clone https://github.com/khoj-ai/khoj && cd khoj python3 -m venv .venv && source .venv/bin/activate # Install Khoj for Development -pip install -e .[dev] +pip install -e '.[dev]' + ``` + + +``` +#### 2. Postgres Installation & Setup + +Khoj uses the `pgvector` package to store embeddings of your index in a Postgres database. To use this, you need to have Postgres installed. + +```mdx-code-block + + +Install [Postgres.app](https://postgresapp.com/). This comes pre-installed with `pgvector` and relevant dependencies. + + + 1. Use the [recommended installer](https://www.postgresql.org/download/windows/). + 2. Follow instructions to [Install PgVector](https://github.com/pgvector/pgvector#windows) in case you need to manually install it. Windows support is experimental for pgvector currently, so we recommend using Docker. Refer to Windows Installation Notes below if there are errors. + + + From [official instructions](https://wiki.postgresql.org/wiki/Apt) + + + 1. Follow instructions to [Install Postgres](https://www.postgresql.org/download/) + 2. Follow instructions to [Install PgVector](https://github.com/pgvector/pgvector#installation) in case you need to manually install it. + + +``` + +##### Create the Khoj database + +Make sure to update your environment variables to match your Postgres configuration if you're using a different name. The default values should work for most people. When prompted for a password, you can use the default password `postgres`, or configure it to your preference. Make sure to set the environment variable `POSTGRES_PASSWORD` to the same value as the password you set here. + +```mdx-code-block + + + ```shell +createdb khoj -U postgres --password + ``` + + + ```shell +createdb -U postgres khoj --password + ``` + + + ```shell +sudo -u postgres createdb khoj --password ``` ``` - -#### 2. Run +#### 3. Run 1. Start Khoj ```bash khoj -vv @@ -72,6 +117,37 @@ pip install -e .[dev] Note: Wait after configuration for khoj to Load ML model, generate embeddings and expose API to query notes, images, documents etc specified in config YAML +#### Windows Installation Notes +1. Command `khoj` Not Recognized + - Try reactivating the virtual environment and rerunning the `khoj` command. + - If it still doesn't work repeat the installation process. +2. Python Package Missing + - Use `pip install xxx` and try running the `khoj` command. +3. Command `createdb` Not Recognized + - make sure path to postgres binaries is included in environment variables. It usually looks something like + ``` + C:\Program Files\PostgreSQL\16\bin + ``` +4. Connection Refused on port xxxx + - Locate the `pg_hba.conf` file in the location where postgres was installed. + - Edit the file to have **trust** as the method for user postgres, local, and host connections. + - Below is an example: +``` +host all postgres 127.0.0.1/32 trust +# "local" is for Unix domain socket connections only +local all all trust +# IPv4 local connections: +host all all 127.0.0.1/32 trust +# IPv6 local connections: +host all all ::1/128 trust +``` +4. Errors with installing pgvector + - Reinstall Visual Studio 2022 Build Tools with: + 1. desktop development with c++ selected in workloads + 2. MSVC (C++ Build Tools), Windows 10/11 SDK, and C++/CLI support for build tools selected in individual components. + - Open the x64 Native Tools Command Prompt as an Administrator + - Follow the pgvector windows installation [instructions](https://github.com/pgvector/pgvector?tab=readme-ov-file#windows) in this command prompt. + ### Using Docker Make sure you install the latest version of [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/). diff --git a/documentation/docs/data-sources/notion_integration.md b/documentation/docs/data-sources/notion_integration.md index 67d0d5bc..23fe9f32 100644 --- a/documentation/docs/data-sources/notion_integration.md +++ b/documentation/docs/data-sources/notion_integration.md @@ -4,7 +4,7 @@ The Notion integration allows you to search/chat with your Notion workspaces. [N Go to https://app.khoj.dev/config to connect your Notion workspace(s) to Khoj. -![notion_integration](/img/notion_integration.gif) +![notion_integration](https://assets.khoj.dev/notion_integration.gif) ## Self-Hosted Setup diff --git a/documentation/docs/features/agents.md b/documentation/docs/features/agents.md index 249f5bde..bf74671e 100644 --- a/documentation/docs/features/agents.md +++ b/documentation/docs/features/agents.md @@ -6,7 +6,7 @@ sidebar_position: 4 You can use agents to setup custom system prompts with Khoj. The server host can setup their own agents, which are accessible to all users. You can see ours at https://app.khoj.dev/agents. -![Demo](/img/agents_demo.gif) +![Demo](https://assets.khoj.dev/agents_demo.gif) ## Creating an Agent (Self-Hosted) diff --git a/documentation/docs/features/share.md b/documentation/docs/features/share.md new file mode 100644 index 00000000..0a20bb41 --- /dev/null +++ b/documentation/docs/features/share.md @@ -0,0 +1,7 @@ +# Shareable Chat + +You can share any of your conversations by going to the three dot menu on the conversation and selecting 'Share'. This will create a **public** link that you can share with anyone. The link will open the conversation in the same state it was when you shared it, so your future messages will not be visible to the person you shared it with. + +This means you can easily share a conversation with someone to show them how you solved a problem, or to get help with something you're working on. + +![demo of sharing a conversation](https://assets.khoj.dev/shareable_conversations.gif) diff --git a/documentation/docs/get-started/overview.md b/documentation/docs/get-started/overview.md index 57f93804..a30193e4 100644 --- a/documentation/docs/get-started/overview.md +++ b/documentation/docs/get-started/overview.md @@ -38,7 +38,7 @@ Welcome to the Khoj Docs! This is the best place to get setup and explore Khoj's - [Read these instructions](/get-started/setup) to self-host a private instance of Khoj ## At a Glance -![demo_chat](/img/using_khoj_for_studying.gif) +![demo_chat](https://assets.khoj.dev/using_khoj_for_studying.gif) #### [Search](/features/search) - **Natural**: Use natural language queries to quickly find relevant notes and documents. diff --git a/documentation/docs/get-started/setup.mdx b/documentation/docs/get-started/setup.mdx index b9f32048..cbf2af7c 100644 --- a/documentation/docs/get-started/setup.mdx +++ b/documentation/docs/get-started/setup.mdx @@ -59,6 +59,7 @@ Khoj uses the `pgvector` package to store embeddings of your index in a Postgres Install [Postgres.app](https://postgresapp.com/). This comes pre-installed with `pgvector` and relevant dependencies. + For detailed instructions and troubleshooting, see [this section](/contributing/development#2-postgres-installation--setup). 1. Use the [recommended installer](https://www.postgresql.org/download/windows/). 2. Follow instructions to [Install PgVector](https://github.com/pgvector/pgvector#windows) in case you need to manually install it. Windows support is experimental for pgvector currently, so we recommend using Docker. @@ -117,13 +118,14 @@ python -m pip install khoj-assistant ``` + In PowerShell on Windows ```shell # 1. (Optional) To use NVIDIA (CUDA) GPU $env:CMAKE_ARGS = "-DLLAMA_OPENBLAS=on" # 1. (Optional) To use AMD (ROCm) GPU - CMAKE_ARGS="-DLLAMA_HIPBLAS=on" + $env:CMAKE_ARGS = "-DLLAMA_HIPBLAS=on" # 1. (Optional) To use VULCAN GPU - CMAKE_ARGS="-DLLAMA_VULKAN=on" + $env:CMAKE_ARGS = "-DLLAMA_VULKAN=on" # 2. Install Khoj py -m pip install khoj-assistant @@ -201,6 +203,11 @@ To disable HTTPS, set the `KHOJ_NO_HTTPS` environment variable to `True`. This c 1. Go to http://localhost:42110/server/admin and login with your admin credentials. #### Configure Chat Model ##### Configure OpenAI or a custom OpenAI-compatible proxy server + +:::info[Ollama Integration] +Using Ollama? See the [Ollama Integration](/miscellaneous/ollama) section for more custom setup instructions. +::: + 1. Go to the [OpenAI settings](http://localhost:42110/server/admin/database/openaiprocessorconversationconfig/) in the server admin settings to add an OpenAI processor conversation config. This is where you set your API key and server API base URL. The API base URL is optional - it's only relevant if you're using another OpenAI-compatible proxy server. 2. Go over to configure your [chat model options](http://localhost:42110/server/admin/database/chatmodeloptions/). Set the `chat-model` field to a supported chat model[^1] of your choice. For example, you can specify `gpt-4-turbo-preview` if you're using OpenAI. - Make sure to set the `model-type` field to `OpenAI`. diff --git a/documentation/docs/miscellaneous/ollama.md b/documentation/docs/miscellaneous/ollama.md new file mode 100644 index 00000000..dc408b2f --- /dev/null +++ b/documentation/docs/miscellaneous/ollama.md @@ -0,0 +1,33 @@ +# Ollama / Khoj + +You can run your own open source models locally with Ollama and use them with Khoj. + +:::info[Ollama Integration] +This is only going to be helpful for self-hosted users. If you're using [Khoj Cloud](https://app.khoj.dev), you're limited to our first-party models. +::: + +Khoj supports any OpenAI-API compatible server, which includes [Ollama](http://ollama.ai/). Ollama allows you to start a local server with [several popular open-source LLMs](https://ollama.com/library) directly on your own computer. Combined with Khoj, you can chat with these LLMs and use them to search your notes and documents. + +While Khoj also supports local-hosted LLMs downloaded from Hugging Face, the Ollama integration is particularly useful for its ease of setup and multi-model support, especially if you're already using Ollama. + +## Setup + +1. Setup Ollama: https://ollama.com/ +2. Start your preferred model with Ollama. For example, + ```bash + ollama run llama3 + ``` +3. Go to Khoj settings at [OpenAI Processor Conversation Config](http://localhost:42110/server/admin/database/openaiprocessorconversationconfig/) +4. Create a new config. + - Name: `ollama` + - Api Key: `any string` + - Api Base Url: `http://localhost:11434/v1/` (default for Ollama) +5. Go to [Chat Model Options](http://localhost:42110/server/admin/database/chatmodeloptions/) +6. Create a new config. + - Name: `llama3` (replace with the name of your local model) + - Model Type: `Openai` + - Openai Config: `` + - Max prompt size: `1000` (replace with the max prompt size of your model) +7. Go to [your config](http://localhost:42110/config) and select the model you just created in the chat model dropdown. + +That's it! You should now be able to chat with your Ollama model from Khoj. If you want to add additional models running on Ollama, repeat step 6 for each model. diff --git a/documentation/docs/miscellaneous/telemetry.md b/documentation/docs/miscellaneous/telemetry.md index d484d1fa..2d1bd2af 100644 --- a/documentation/docs/miscellaneous/telemetry.md +++ b/documentation/docs/miscellaneous/telemetry.md @@ -16,7 +16,11 @@ We don't send any personal information or any information from/about your conten If you're self-hosting Khoj, you can opt out of telemetry at any time. To do so, 1. Open `~/.khoj/khoj.yml` -2. Set `should-log-telemetry` to `false` +2. Add the following configuration: +``` +app: + should-log-telemetry: false +``` 3. Save the file and restart Khoj If you have any questions or concerns, please reach out to us on [Discord](https://discord.gg/BDgyabRM6e). diff --git a/manifest.json b/manifest.json index d9cbf4bd..21d72dca 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "khoj", "name": "Khoj", - "version": "1.12.0", + "version": "1.12.1", "minAppVersion": "0.15.0", "description": "An AI copilot for your Second Brain", "author": "Khoj Inc.", diff --git a/pyproject.toml b/pyproject.toml index 06b2da55..85664ec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "aiohttp ~= 3.9.0", "langchain <= 0.2.0", "langchain-openai >= 0.0.5", + "langchain-community == 0.0.27", "requests >= 2.26.0", "anyio == 3.7.1", "pymupdf >= 1.23.5", @@ -84,6 +85,7 @@ dependencies = [ "pytz ~= 2024.1", "cron-descriptor == 1.4.3", "django_apscheduler == 0.6.2", + "anthropic == 0.26.1", ] dynamic = ["version"] @@ -102,7 +104,7 @@ prod = [ "stripe == 7.3.0", "twilio == 8.11", "boto3 >= 1.34.57", - "resend >= 0.8.0", + "resend == 1.0.1", ] dev = [ "khoj-assistant[prod]", diff --git a/src/interface/desktop/assets/khoj.css b/src/interface/desktop/assets/khoj.css index 521936b8..9269f649 100644 --- a/src/interface/desktop/assets/khoj.css +++ b/src/interface/desktop/assets/khoj.css @@ -188,12 +188,14 @@ img.khoj-logo { .khoj-nav-dropdown-content.show { opacity: 1; pointer-events: auto; + border-radius: 20px; } .khoj-nav-dropdown-content a { color: black; padding: 12px 16px; text-decoration: none; display: block; + border-radius: 20px; } .khoj-nav-dropdown-content a:hover { background-color: var(--primary-hover); diff --git a/src/interface/desktop/chat.html b/src/interface/desktop/chat.html index b1b31b71..5078904b 100644 --- a/src/interface/desktop/chat.html +++ b/src/interface/desktop/chat.html @@ -334,6 +334,16 @@ let anchorElements = element.querySelectorAll('a'); anchorElements.forEach((anchorElement) => { + // Tag external links to open in separate window + if ( + !anchorElement.href.startsWith("./") && + !anchorElement.href.startsWith("#") && + !anchorElement.href.startsWith("/") + ) { + anchorElement.setAttribute('target', '_blank'); + anchorElement.setAttribute('rel', 'noopener noreferrer'); + } + // Add the class "inline-chat-link" to each element anchorElement.classList.add("inline-chat-link"); }); @@ -1024,11 +1034,12 @@ threeDotMenu.appendChild(conversationMenu); let deleteButton = document.createElement('button'); + deleteButton.type = "button"; deleteButton.innerHTML = "Delete"; deleteButton.classList.add("delete-conversation-button"); deleteButton.classList.add("three-dot-menu-button-item"); - deleteButton.addEventListener('click', function() { - // Ask for confirmation before deleting chat session + deleteButton.addEventListener('click', function(event) { + event.preventDefault(); let confirmation = confirm('Are you sure you want to delete this chat session?'); if (!confirmation) return; let deleteURL = `/api/chat/history?client=web&conversation_id=${incomingConversationId}`; @@ -1643,9 +1654,10 @@ content: "▶"; margin-right: 5px; display: inline-block; - transition: transform 0.3s ease-in-out; + transition: transform 0.1s ease-in-out; } + button.reference-button.expanded::before, button.reference-button:active:before, button.reference-button[aria-expanded="true"]::before { transform: rotate(90deg); @@ -1876,6 +1888,7 @@ text-align: left; display: flex; position: relative; + margin: 0 8px; } .three-dot-menu { diff --git a/src/interface/desktop/package.json b/src/interface/desktop/package.json index 91c76e28..dfc52cbf 100644 --- a/src/interface/desktop/package.json +++ b/src/interface/desktop/package.json @@ -1,6 +1,6 @@ { "name": "Khoj", - "version": "1.12.0", + "version": "1.12.1", "description": "An AI copilot for your Second Brain", "author": "Saba Imran, Debanjum Singh Solanky ", "license": "GPL-3.0-or-later", diff --git a/src/interface/desktop/utils.js b/src/interface/desktop/utils.js index 49872dcf..c880a7cd 100644 --- a/src/interface/desktop/utils.js +++ b/src/interface/desktop/utils.js @@ -84,6 +84,7 @@ async function populateHeaderPane() { `}
${username}
+ GitHub ⚙️ Settings
diff --git a/src/interface/emacs/khoj.el b/src/interface/emacs/khoj.el index b2fe9f91..05357930 100644 --- a/src/interface/emacs/khoj.el +++ b/src/interface/emacs/khoj.el @@ -6,7 +6,7 @@ ;; Saba Imran ;; Description: An AI copilot for your Second Brain ;; Keywords: search, chat, org-mode, outlines, markdown, pdf, image -;; Version: 1.12.0 +;; Version: 1.12.1 ;; Package-Requires: ((emacs "27.1") (transient "0.3.0") (dash "2.19.1")) ;; URL: https://github.com/khoj-ai/khoj/tree/master/src/interface/emacs diff --git a/src/interface/obsidian/manifest.json b/src/interface/obsidian/manifest.json index d9cbf4bd..21d72dca 100644 --- a/src/interface/obsidian/manifest.json +++ b/src/interface/obsidian/manifest.json @@ -1,7 +1,7 @@ { "id": "khoj", "name": "Khoj", - "version": "1.12.0", + "version": "1.12.1", "minAppVersion": "0.15.0", "description": "An AI copilot for your Second Brain", "author": "Khoj Inc.", diff --git a/src/interface/obsidian/package.json b/src/interface/obsidian/package.json index 47518d94..bf753408 100644 --- a/src/interface/obsidian/package.json +++ b/src/interface/obsidian/package.json @@ -1,6 +1,6 @@ { "name": "Khoj", - "version": "1.12.0", + "version": "1.12.1", "description": "An AI copilot for your Second Brain", "author": "Debanjum Singh Solanky, Saba Imran ", "license": "GPL-3.0-or-later", diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index f37e7608..86401a5e 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -193,8 +193,9 @@ button.reference-button::before { content: "▶"; margin-right: 5px; display: inline-block; - transition: transform 0.3s ease-in-out; + transition: transform 0.1s ease-in-out; } +button.reference-button.expanded::before, button.reference-button:active:before, button.reference-button[aria-expanded="true"]::before { transform: rotate(90deg); diff --git a/src/interface/obsidian/versions.json b/src/interface/obsidian/versions.json index 8aaaaeb1..d143e52c 100644 --- a/src/interface/obsidian/versions.json +++ b/src/interface/obsidian/versions.json @@ -48,5 +48,6 @@ "1.11.0": "0.15.0", "1.11.1": "0.15.0", "1.11.2": "0.15.0", - "1.12.0": "0.15.0" + "1.12.0": "0.15.0", + "1.12.1": "0.15.0" } diff --git a/src/khoj/app/settings.py b/src/khoj/app/settings.py index 2672f98d..98bc95cf 100644 --- a/src/khoj/app/settings.py +++ b/src/khoj/app/settings.py @@ -110,7 +110,6 @@ TEMPLATES = [ WSGI_APPLICATION = "app.wsgi.application" - # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases @@ -122,6 +121,7 @@ DATABASES = { "USER": os.getenv("POSTGRES_USER", "postgres"), "NAME": os.getenv("POSTGRES_DB", "khoj"), "PASSWORD": os.getenv("POSTGRES_PASSWORD", "postgres"), + "CONN_MAX_AGE": 200, } } diff --git a/src/khoj/database/adapters/__init__.py b/src/khoj/database/adapters/__init__.py index fac2ca27..0359a6e3 100644 --- a/src/khoj/database/adapters/__init__.py +++ b/src/khoj/database/adapters/__init__.py @@ -36,8 +36,10 @@ from khoj.database.models import ( NotionConfig, OpenAIProcessorConversationConfig, ProcessLock, + PublicConversation, ReflectiveQuestion, SearchModelConfig, + ServerChatSettings, SpeechToTextModelOptions, Subscription, TextToImageModelConfig, @@ -438,20 +440,21 @@ class ProcessLockAdapters: return True @staticmethod - def remove_process_lock(process_name: str): - return ProcessLock.objects.filter(name=process_name).delete() + def remove_process_lock(process_lock: ProcessLock): + return process_lock.delete() @staticmethod def run_with_lock(func: Callable, operation: ProcessLock.Operation, max_duration_in_seconds: int = 600, **kwargs): # Exit early if process lock is already taken if ProcessLockAdapters.is_process_locked(operation): - logger.info(f"🔒 Skip executing {func} as {operation} lock is already taken") + logger.debug(f"🔒 Skip executing {func} as {operation} lock is already taken") return success = False + process_lock = None try: # Set process lock - ProcessLockAdapters.set_process_lock(operation, max_duration_in_seconds) + process_lock = ProcessLockAdapters.set_process_lock(operation, max_duration_in_seconds) logger.info(f"🔐 Locked {operation} to execute {func}") # Execute Function @@ -459,15 +462,20 @@ class ProcessLockAdapters: func(**kwargs) success = True except IntegrityError as e: - logger.error(f"⚠️ Unable to create the process lock for {func} with {operation}: {e}", exc_info=True) + logger.debug(f"⚠️ Unable to create the process lock for {func} with {operation}: {e}") success = False except Exception as e: logger.error(f"🚨 Error executing {func} with {operation} process lock: {e}", exc_info=True) success = False finally: # Remove Process Lock - ProcessLockAdapters.remove_process_lock(operation) - logger.info(f"🔓 Unlocked {operation} process after executing {func} {'Succeeded' if success else 'Failed'}") + if process_lock: + ProcessLockAdapters.remove_process_lock(process_lock) + logger.info( + f"🔓 Unlocked {operation} process after executing {func} {'Succeeded' if success else 'Failed'}" + ) + else: + logger.debug(f"Skip removing {operation} process lock as it was not set") def run_with_process_lock(*args, **kwargs): @@ -560,7 +568,28 @@ class AgentAdapters: return await Agent.objects.filter(name=AgentAdapters.DEFAULT_AGENT_NAME).afirst() +class PublicConversationAdapters: + @staticmethod + def get_public_conversation_by_slug(slug: str): + return PublicConversation.objects.filter(slug=slug).first() + + @staticmethod + def get_public_conversation_url(public_conversation: PublicConversation): + # Public conversations are viewable by anyone, but not editable. + return f"/share/chat/{public_conversation.slug}/" + + class ConversationAdapters: + @staticmethod + def make_public_conversation_copy(conversation: Conversation): + return PublicConversation.objects.create( + source_owner=conversation.user, + agent=conversation.agent, + conversation_log=conversation.conversation_log, + slug=conversation.slug, + title=conversation.title, + ) + @staticmethod def get_conversation_by_user( user: KhojUser, client_application: ClientApplication = None, conversation_id: int = None @@ -674,11 +703,49 @@ class ConversationAdapters: @staticmethod def get_default_conversation_config(): - return ChatModelOptions.objects.filter().first() + server_chat_settings = ServerChatSettings.objects.first() + if server_chat_settings is None or server_chat_settings.default_model is None: + return ChatModelOptions.objects.filter().first() + return server_chat_settings.default_model @staticmethod async def aget_default_conversation_config(): - return await ChatModelOptions.objects.filter().prefetch_related("openai_config").afirst() + server_chat_settings: ServerChatSettings = ( + await ServerChatSettings.objects.filter() + .prefetch_related("default_model", "default_model__openai_config") + .afirst() + ) + if server_chat_settings is None or server_chat_settings.default_model is None: + return await ChatModelOptions.objects.filter().prefetch_related("openai_config").afirst() + return server_chat_settings.default_model + + @staticmethod + async def aget_summarizer_conversation_config(): + server_chat_settings: ServerChatSettings = ( + await ServerChatSettings.objects.filter() + .prefetch_related( + "summarizer_model", "default_model", "default_model__openai_config", "summarizer_model__openai_config" + ) + .afirst() + ) + if server_chat_settings is None or ( + server_chat_settings.summarizer_model is None and server_chat_settings.default_model is None + ): + return await ChatModelOptions.objects.filter().afirst() + return server_chat_settings.summarizer_model or server_chat_settings.default_model + + @staticmethod + def create_conversation_from_public_conversation( + user: KhojUser, public_conversation: PublicConversation, client_app: ClientApplication + ): + return Conversation.objects.create( + user=user, + conversation_log=public_conversation.conversation_log, + client=client_app, + slug=public_conversation.slug, + title=public_conversation.title, + agent=public_conversation.agent, + ) @staticmethod def save_conversation( @@ -766,7 +833,9 @@ class ConversationAdapters: return conversation_config - if conversation_config.model_type == "openai" and conversation_config.openai_config: + if ( + conversation_config.model_type == "openai" or conversation_config.model_type == "anthropic" + ) and conversation_config.openai_config: return conversation_config else: diff --git a/src/khoj/database/admin.py b/src/khoj/database/admin.py index 260c4124..a96a57a0 100644 --- a/src/khoj/database/admin.py +++ b/src/khoj/database/admin.py @@ -1,9 +1,13 @@ import csv import json +from apscheduler.job import Job from django.contrib import admin from django.contrib.auth.admin import UserAdmin 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 ( Agent, @@ -18,6 +22,7 @@ from khoj.database.models import ( ProcessLock, ReflectiveQuestion, SearchModelConfig, + ServerChatSettings, SpeechToTextModelOptions, Subscription, TextToImageModelConfig, @@ -25,6 +30,35 @@ from khoj.database.models import ( ) 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): list_display = ( @@ -44,12 +78,9 @@ class KhojUserAdmin(UserAdmin): admin.site.register(KhojUser, KhojUserAdmin) -admin.site.register(ChatModelOptions) admin.site.register(ProcessLock) admin.site.register(SpeechToTextModelOptions) -admin.site.register(OpenAIProcessorConversationConfig) admin.site.register(SearchModelConfig) -admin.site.register(Subscription) admin.site.register(ReflectiveQuestion) admin.site.register(UserSearchModelConfig) admin.site.register(TextToImageModelConfig) @@ -85,6 +116,48 @@ class EntryAdmin(admin.ModelAdmin): ordering = ("-created_at",) +@admin.register(Subscription) +class KhojUserSubscription(admin.ModelAdmin): + list_display = ( + "id", + "user", + "type", + ) + + search_fields = ("id", "user__email", "user__username", "type") + list_filter = ("type",) + + +@admin.register(ChatModelOptions) +class ChatModelOptionsAdmin(admin.ModelAdmin): + list_display = ( + "id", + "chat_model", + "model_type", + "max_prompt_size", + ) + search_fields = ("id", "chat_model", "model_type") + + +@admin.register(OpenAIProcessorConversationConfig) +class OpenAIProcessorConversationConfigAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "api_key", + "api_base_url", + ) + search_fields = ("id", "name", "api_key", "api_base_url") + + +@admin.register(ServerChatSettings) +class ServerChatSettingsAdmin(admin.ModelAdmin): + list_display = ( + "default_model", + "summarizer_model", + ) + + @admin.register(Conversation) class ConversationAdmin(admin.ModelAdmin): list_display = ( diff --git a/src/khoj/database/migrations/0036_publicconversation.py b/src/khoj/database/migrations/0036_publicconversation.py new file mode 100644 index 00000000..54a98fa1 --- /dev/null +++ b/src/khoj/database/migrations/0036_publicconversation.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.10 on 2024-04-17 13:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0035_processlock"), + ] + + operations = [ + migrations.CreateModel( + name="PublicConversation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("conversation_log", models.JSONField(default=dict)), + ("slug", models.CharField(blank=True, default=None, max_length=200, null=True)), + ("title", models.CharField(blank=True, default=None, max_length=200, null=True)), + ( + "agent", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="database.agent", + ), + ), + ( + "source_owner", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/khoj/database/migrations/0040_merge_20240504_1010.py b/src/khoj/database/migrations/0040_merge_20240504_1010.py new file mode 100644 index 00000000..3abedb3c --- /dev/null +++ b/src/khoj/database/migrations/0040_merge_20240504_1010.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.10 on 2024-05-04 10:10 + +from typing import List + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0036_publicconversation"), + ("database", "0039_merge_20240501_0301"), + ] + + operations: List[str] = [] diff --git a/src/khoj/database/migrations/0041_merge_20240505_1234.py b/src/khoj/database/migrations/0041_merge_20240505_1234.py new file mode 100644 index 00000000..b3cea861 --- /dev/null +++ b/src/khoj/database/migrations/0041_merge_20240505_1234.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.10 on 2024-05-05 12:34 + +from typing import List + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0040_alter_processlock_name"), + ("database", "0040_merge_20240504_1010"), + ] + + operations: List[str] = [] diff --git a/src/khoj/database/migrations/0042_serverchatsettings.py b/src/khoj/database/migrations/0042_serverchatsettings.py new file mode 100644 index 00000000..a58b9729 --- /dev/null +++ b/src/khoj/database/migrations/0042_serverchatsettings.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.10 on 2024-04-29 11:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0041_merge_20240505_1234"), + ] + + operations = [ + migrations.CreateModel( + name="ServerChatSettings", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "default_model", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="default_model", + to="database.chatmodeloptions", + ), + ), + ( + "summarizer_model", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="summarizer_model", + to="database.chatmodeloptions", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/khoj/database/migrations/0043_alter_chatmodeloptions_model_type.py b/src/khoj/database/migrations/0043_alter_chatmodeloptions_model_type.py new file mode 100644 index 00000000..246c78be --- /dev/null +++ b/src/khoj/database/migrations/0043_alter_chatmodeloptions_model_type.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.10 on 2024-05-26 12:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0042_serverchatsettings"), + ] + + operations = [ + migrations.AlterField( + model_name="chatmodeloptions", + name="model_type", + field=models.CharField( + choices=[("openai", "Openai"), ("offline", "Offline"), ("anthropic", "Anthropic")], + default="offline", + max_length=200, + ), + ), + ] diff --git a/src/khoj/database/models/__init__.py b/src/khoj/database/models/__init__.py index e654903d..def10c0a 100644 --- a/src/khoj/database/models/__init__.py +++ b/src/khoj/database/models/__init__.py @@ -1,3 +1,4 @@ +import re import uuid from random import choice @@ -83,6 +84,7 @@ class ChatModelOptions(BaseModel): class ModelType(models.TextChoices): OPENAI = "openai" OFFLINE = "offline" + ANTHROPIC = "anthropic" max_prompt_size = models.IntegerField(default=None, null=True, blank=True) tokenizer = models.CharField(max_length=200, default=None, null=True, blank=True) @@ -157,6 +159,15 @@ class GithubRepoConfig(BaseModel): github_config = models.ForeignKey(GithubConfig, on_delete=models.CASCADE, related_name="githubrepoconfig") +class ServerChatSettings(BaseModel): + default_model = models.ForeignKey( + ChatModelOptions, on_delete=models.CASCADE, default=None, null=True, blank=True, related_name="default_model" + ) + summarizer_model = models.ForeignKey( + ChatModelOptions, on_delete=models.CASCADE, default=None, null=True, blank=True, related_name="summarizer_model" + ) + + class LocalOrgConfig(BaseModel): input_files = models.JSONField(default=list, null=True) input_filter = models.JSONField(default=list, null=True) @@ -249,6 +260,36 @@ class Conversation(BaseModel): agent = models.ForeignKey(Agent, on_delete=models.SET_NULL, default=None, null=True, blank=True) +class PublicConversation(BaseModel): + source_owner = models.ForeignKey(KhojUser, on_delete=models.CASCADE) + conversation_log = models.JSONField(default=dict) + slug = models.CharField(max_length=200, default=None, null=True, blank=True) + title = models.CharField(max_length=200, default=None, null=True, blank=True) + agent = models.ForeignKey(Agent, on_delete=models.SET_NULL, default=None, null=True, blank=True) + + +@receiver(pre_save, sender=PublicConversation) +def verify_public_conversation(sender, instance, **kwargs): + def generate_random_alphanumeric(length): + characters = "0123456789abcdefghijklmnopqrstuvwxyz" + return "".join(choice(characters) for _ in range(length)) + + # check if this is a new instance + if instance._state.adding: + slug = re.sub(r"\W+", "-", instance.slug.lower())[:50] + observed_random_id = set() + while PublicConversation.objects.filter(slug=slug).exists(): + try: + random_id = generate_random_alphanumeric(7) + except IndexError: + raise ValidationError( + "Unable to generate a unique slug for the Public Conversation. Please try again later." + ) + observed_random_id.add(random_id) + slug = f"{slug}-{random_id}" + instance.slug = slug + + class ReflectiveQuestion(BaseModel): question = models.CharField(max_length=500) user = models.ForeignKey(KhojUser, on_delete=models.CASCADE, default=None, null=True, blank=True) diff --git a/src/khoj/interface/email/feedback.html b/src/khoj/interface/email/feedback.html new file mode 100644 index 00000000..79234b56 --- /dev/null +++ b/src/khoj/interface/email/feedback.html @@ -0,0 +1,34 @@ + + + + Khoj Feedback Form + + + + + +
+
+

User Feedback:

+
+

User Query

+

{{uquery}}

+
+
+

Khoj's Response

+ {{kquery}} +
+
+

Sentiment

+

{{sentiment}}

+
+
+

User Email

+

{{user_email}}

+
+
+
+ + diff --git a/src/khoj/interface/web/agents.html b/src/khoj/interface/web/agents.html index 21a5cda2..36bb4caf 100644 --- a/src/khoj/interface/web/agents.html +++ b/src/khoj/interface/web/agents.html @@ -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); + } + } + + - + @@ -179,11 +178,30 @@ To get started, just start typing below. You can also type / to see a list of co return referenceButton; } - - function renderMessage(message, by, dt=null, annotations=null, raw=false, renderType="append") { + var khojQuery = ""; + function renderMessage(message, by, dt=null, annotations=null, raw=false, renderType="append", userQuery=null) { let message_time = formatDate(dt ?? new Date()); let by_name = by == "khoj" ? "🏮 Khoj" : "🤔 You"; - let formattedMessage = formatHTMLMessage(message, raw); + let formattedMessage = formatHTMLMessage(message, raw, true, userQuery); + //update userQuery or khojQuery to latest query for feedback purposes + if(by !== "khoj"){ + raw = formattedMessage.innerHTML; + } + + //find the thumbs up and thumbs down buttons from the message formatter + var thumbsUpButtons = formattedMessage.querySelectorAll('.thumbs-up-button'); + var thumbsDownButtons = formattedMessage.querySelectorAll('.thumbs-down-button'); + + //only render the feedback options if the message is a response from khoj + if(by !== "khoj"){ + thumbsUpButtons.forEach(function(element) { + element.parentNode.removeChild(element); + }); + thumbsDownButtons.forEach(function(element) { + element.parentNode.removeChild(element); + }); + } + // Create a new div for the chat message let chatMessage = document.createElement('div'); @@ -303,7 +321,22 @@ To get started, just start typing below. You can also type / to see a list of co return imageMarkdown; } - function formatHTMLMessage(message, raw=false, willReplace=true) { + //handler function for posting feedback data to endpoint + function sendFeedback(_uquery="", _kquery="", _sentiment="") { + const uquery = _uquery; + const kquery = _kquery; + const sentiment = _sentiment; + fetch('/api/chat/feedback', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({uquery: uquery, kquery: kquery, sentiment: sentiment}) + }) + .then(response => response.json()) + } + + function formatHTMLMessage(message, raw=false, willReplace=true, userQuery) { var md = window.markdownit(); let newHTML = message; @@ -347,7 +380,35 @@ To get started, just start typing below. You can also type / to see a list of co copyIcon.classList.add("copy-icon"); copyButton.appendChild(copyIcon); copyButton.addEventListener('click', createCopyParentText(message)); - element.append(copyButton); + + //create thumbs-up button + let thumbsUpButton = document.createElement('button'); + thumbsUpButton.className = 'thumbs-up-button'; + let thumbsUpIcon = document.createElement("img"); + thumbsUpIcon.src = "/static/assets/icons/thumbs-up-svgrepo-com.svg"; + thumbsUpIcon.classList.add("thumbs-up-icon"); + thumbsUpButton.appendChild(thumbsUpIcon); + thumbsUpButton.onclick = function() { + khojQuery = newHTML; + thumbsUpIcon.src = "/static/assets/icons/confirm-icon.svg"; + sendFeedback(userQuery ,khojQuery, "Good Response"); + }; + + // Create thumbs-down button + let thumbsDownButton = document.createElement('button'); + thumbsDownButton.className = 'thumbs-down-button'; + let thumbsDownIcon = document.createElement("img"); + thumbsDownIcon.src = "/static/assets/icons/thumbs-down-svgrepo-com.svg"; + thumbsDownIcon.classList.add("thumbs-down-icon"); + thumbsDownButton.appendChild(thumbsDownIcon); + thumbsDownButton.onclick = function() { + khojQuery = newHTML; + thumbsDownIcon.src = "/static/assets/icons/confirm-icon.svg"; + sendFeedback(userQuery, khojQuery, "Bad Response"); + }; + + // Append buttons to parent element + element.append(copyButton, thumbsDownButton, thumbsUpButton); } renderMathInElement(element, { @@ -355,7 +416,6 @@ To get started, just start typing below. You can also type / to see a list of co // • auto-render specific keys, e.g.: delimiters: [ {left: '$$', right: '$$', display: true}, - {left: '$', right: '$', display: false}, {left: '\\(', right: '\\)', display: false}, {left: '\\[', right: '\\]', display: true} ], @@ -546,7 +606,7 @@ To get started, just start typing below. You can also type / to see a list of co } else { // If the chunk is not a JSON object, just display it as is rawResponse += chunk; - handleStreamResponse(newResponseText, rawResponse, loadingEllipsis); + handleStreamResponse(newResponseText, rawResponse, query, loadingEllipsis); readStream(); } }); @@ -582,14 +642,14 @@ To get started, just start typing below. You can also type / to see a list of co return loadingEllipsis; } - function handleStreamResponse(newResponseElement, rawResponse, loadingEllipsis, replace=true) { + function handleStreamResponse(newResponseElement, rawResponse, rawQuery, loadingEllipsis, replace=true) { if (newResponseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) { newResponseElement.removeChild(loadingEllipsis); } if (replace) { newResponseElement.innerHTML = ""; } - newResponseElement.appendChild(formatHTMLMessage(rawResponse, false, replace)); + newResponseElement.appendChild(formatHTMLMessage(rawResponse, false, replace, rawQuery)); document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; } @@ -889,6 +949,7 @@ To get started, just start typing below. You can also type / to see a list of co loadingEllipsis: null, references: {}, rawResponse: "", + rawQuery: "", } if (chatBody.dataset.conversationId) { @@ -907,6 +968,7 @@ To get started, just start typing below. You can also type / to see a list of co // Append any references after all the data has been streamed finalizeChatBodyResponse(websocketState.references, websocketState.newResponseTextEl); + const liveQuery = websocketState.rawQuery; // Reset variables websocketState = { newResponseTextEl: null, @@ -914,6 +976,7 @@ To get started, just start typing below. You can also type / to see a list of co loadingEllipsis: null, references: {}, rawResponse: "", + rawQuery: liveQuery, } } else { try { @@ -935,9 +998,9 @@ To get started, just start typing below. You can also type / to see a list of co websocketState.rawResponse = rawResponse; websocketState.references = references; } else if (chunk.type == "status") { - handleStreamResponse(websocketState.newResponseTextEl, chunk.message, null, false); + handleStreamResponse(websocketState.newResponseTextEl, chunk.message, websocketState.rawQuery, null, false); } else if (chunk.type == "rate_limit") { - handleStreamResponse(websocketState.newResponseTextEl, chunk.message, websocketState.loadingEllipsis, true); + handleStreamResponse(websocketState.newResponseTextEl, chunk.message, websocketState.rawQuery, websocketState.loadingEllipsis, true); } else { rawResponse = chunk.response; } @@ -960,7 +1023,7 @@ To get started, just start typing below. You can also type / to see a list of co // If the chunk is not a JSON object, just display it as is websocketState.rawResponse += chunk; if (websocketState.newResponseTextEl) { - handleStreamResponse(websocketState.newResponseTextEl, websocketState.rawResponse, websocketState.loadingEllipsis); + handleStreamResponse(websocketState.newResponseTextEl, websocketState.rawResponse, websocketState.rawQuery, websocketState.loadingEllipsis); } } @@ -1037,6 +1100,7 @@ To get started, just start typing below. You can also type / to see a list of co loadingEllipsis, references, rawResponse, + rawQuery: query, } } @@ -1154,7 +1218,8 @@ To get started, just start typing below. You can also type / to see a list of co new Date(chat_log.created + "Z"), chat_log.onlineContext, chat_log.intent?.type, - chat_log.intent?.["inferred-queries"]); + chat_log.intent?.["inferred-queries"], + chat_log.intent?.query); chatBody.appendChild(messageElement); // When the 4th oldest message is within viewing distance (~60% scroll up) @@ -1249,7 +1314,8 @@ To get started, just start typing below. You can also type / to see a list of co new Date(chat_log.created + "Z"), chat_log.onlineContext, chat_log.intent?.type, - chat_log.intent?.["inferred-queries"] + chat_log.intent?.["inferred-queries"], + chat_log.intent?.query ); entry.target.replaceWith(messageElement); @@ -1512,11 +1578,79 @@ To get started, just start typing below. You can also type / to see a list of co conversationMenu.appendChild(editTitleButton); threeDotMenu.appendChild(conversationMenu); + let shareButton = document.createElement('button'); + shareButton.innerHTML = "Share"; + shareButton.type = "button"; + shareButton.classList.add("share-conversation-button"); + shareButton.classList.add("three-dot-menu-button-item"); + shareButton.addEventListener('click', function(event) { + event.preventDefault(); + let confirmation = confirm('Are you sure you want to share this chat session? This will make the conversation public.'); + if (!confirmation) return; + let duplicateURL = `/api/chat/share?client=web&conversation_id=${incomingConversationId}`; + fetch(duplicateURL , { method: "POST" }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { + if (data.status == "ok") { + flashStatusInChatInput("✅ Conversation shared successfully"); + } + // Make a pop-up that shows data.url to share the conversation + let shareURL = data.url; + let shareModal = document.createElement('div'); + shareModal.classList.add("modal"); + shareModal.id = "share-conversation-modal"; + let shareModalContent = document.createElement('div'); + shareModalContent.classList.add("modal-content"); + let shareModalHeader = document.createElement('div'); + shareModalHeader.classList.add("modal-header"); + let shareModalTitle = document.createElement('h2'); + shareModalTitle.textContent = "Share Conversation"; + let shareModalCloseButton = document.createElement('button'); + shareModalCloseButton.classList.add("modal-close-button"); + shareModalCloseButton.innerHTML = "×"; + shareModalCloseButton.addEventListener('click', function() { + shareModal.remove(); + }); + shareModalHeader.appendChild(shareModalTitle); + shareModalHeader.appendChild(shareModalCloseButton); + shareModalContent.appendChild(shareModalHeader); + let shareModalBody = document.createElement('div'); + shareModalBody.classList.add("modal-body"); + let shareModalText = document.createElement('p'); + shareModalText.textContent = "The link has been copied to your clipboard. Use it to share your conversation with others!"; + let shareModalLink = document.createElement('input'); + shareModalLink.setAttribute("value", shareURL); + shareModalLink.setAttribute("readonly", ""); + shareModalLink.classList.add("share-link"); + let copyButton = document.createElement('button'); + copyButton.textContent = "Copy"; + copyButton.addEventListener('click', function() { + shareModalLink.select(); + document.execCommand('copy'); + }); + copyButton.id = "copy-share-url-button"; + shareModalBody.appendChild(shareModalText); + shareModalBody.appendChild(shareModalLink); + shareModalBody.appendChild(copyButton); + shareModalContent.appendChild(shareModalBody); + shareModal.appendChild(shareModalContent); + document.body.appendChild(shareModal); + shareModalLink.select(); + document.execCommand('copy'); + }) + .catch(err => { + return; + }); + }); + conversationMenu.appendChild(shareButton); + let deleteButton = document.createElement('button'); + deleteButton.type = "button"; deleteButton.innerHTML = "Delete"; deleteButton.classList.add("delete-conversation-button"); deleteButton.classList.add("three-dot-menu-button-item"); - deleteButton.addEventListener('click', function() { + deleteButton.addEventListener('click', function(event) { + event.preventDefault(); // Ask for confirmation before deleting chat session let confirmation = confirm('Are you sure you want to delete this chat session?'); if (!confirmation) return; @@ -1864,9 +1998,10 @@ To get started, just start typing below. You can also type / to see a list of co content: "▶"; margin-right: 5px; display: inline-block; - transition: transform 0.3s ease-in-out; + transition: transform 0.1s ease-in-out; } + button.reference-button.expanded::before, button.reference-button:active:before, button.reference-button[aria-expanded="true"]::before { transform: rotate(90deg); @@ -2175,7 +2310,7 @@ To get started, just start typing below. You can also type / to see a list of co } .side-panel-button { - background: var(--background-color); + background: none; border: none; box-shadow: none; font-size: 14px; @@ -2274,6 +2409,32 @@ To get started, just start typing below. You can also type / to see a list of co float: right; } + button.thumbs-up-button { + border-radius: 4px; + background-color: var(--background-color); + border: 1px solid var(--main-text-color); + text-align: center; + font-size: 16px; + transition: all 0.5s; + cursor: pointer; + padding: 4px; + float: right; + margin-right: 4px; + } + + button.thumbs-down-button { + border-radius: 4px; + background-color: var(--background-color); + border: 1px solid var(--main-text-color); + text-align: center; + font-size: 16px; + transition: all 0.5s; + cursor: pointer; + padding: 4px; + float: right; + margin-right:4px; + } + button.copy-button span { cursor: pointer; display: inline-block; @@ -2282,8 +2443,18 @@ To get started, just start typing below. You can also type / to see a list of co } img.copy-icon { - width: 16px; - height: 16px; + width: 18px; + height: 18px; + } + + img.thumbs-up-icon { + width: 18px; + height: 18px; + } + + img.thumbs-down-icon { + width: 18px; + height: 18px; } button.copy-button:hover { @@ -2291,6 +2462,16 @@ To get started, just start typing below. You can also type / to see a list of co color: #f5f5f5; } + button.thumbs-up-button:hover { + background-color: var(--primary-hover); + color: #f5f5f5; + } + + button.thumbs-down-button:hover { + background-color: var(--primary-hover); + color: #f5f5f5; + } + pre { text-wrap: unset; } @@ -2394,6 +2575,7 @@ To get started, just start typing below. You can also type / to see a list of co margin-bottom: 8px; } + button#copy-share-url-button, button#new-conversation-button { display: inline-flex; align-items: center; @@ -2414,14 +2596,12 @@ To get started, just start typing below. You can also type / to see a list of co text-align: left; display: flex; position: relative; + margin-right: 8px; } .three-dot-menu { display: block; - /* background: var(--background-color); */ - /* border: 1px solid var(--main-text-color); */ border-radius: 5px; - /* position: relative; */ position: absolute; right: 4px; top: 4px; @@ -2603,13 +2783,6 @@ To get started, just start typing below. You can also type / to see a list of co color: #333; } - #agent-instructions { - font-size: 14px; - color: #666; - height: 50px; - overflow: auto; - } - #agent-owned-by-user { font-size: 12px; color: #007BFF; @@ -2631,7 +2804,7 @@ To get started, just start typing below. You can also type / to see a list of co margin: 15% auto; /* 15% from the top and centered */ padding: 20px; border: 1px solid #888; - width: 250px; + width: 300px; text-align: left; background: var(--background-color); border-radius: 5px; @@ -2705,6 +2878,28 @@ To get started, just start typing below. You can also type / to see a list of co border: 1px solid var(--main-text-color); } + .share-link { + display: block; + width: 100%; + padding: 10px; + margin-top: 10px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f9f9f9; + font-family: 'Courier New', monospace; + color: #333; + font-size: 16px; + box-sizing: border-box; + transition: all 0.3s ease; + } + + .share-link:focus { + outline: none; + border-color: #007BFF; + box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); + } + + button#copy-share-url-button, button#new-conversation-submit-button { background: var(--summer-sun); transition: background 0.2s ease-in-out; @@ -2715,6 +2910,7 @@ To get started, just start typing below. You can also type / to see a list of co transition: background 0.2s ease-in-out; } + button#copy-share-url-button:hover, button#new-conversation-submit-button:hover { background: var(--primary); } diff --git a/src/khoj/interface/web/config.html b/src/khoj/interface/web/config.html index b808ef33..64841f97 100644 --- a/src/khoj/interface/web/config.html +++ b/src/khoj/interface/web/config.html @@ -635,85 +635,6 @@ // List user's API keys on page load listApiKeys(); - function deleteAutomation(automationId) { - const AutomationList = document.getElementById("automations-list"); - fetch(`/api/automation?automation_id=${automationId}`, { - method: 'DELETE', - }) - .then(response => { - if (response.status == 200) { - const AutomationItem = document.getElementById(`automation-item-${automationId}`); - AutomationList.removeChild(AutomationItem); - } - }); - } - - function generateAutomationRow(automationObj) { - let automationId = automationObj.id; - let automationNextRun = `Next run at ${automationObj.next}`; - return ` - - ${automationObj.subject} - ${automationObj.scheduling_request} - ${automationObj.query_to_run} - ${automationObj.schedule} - - Delete Automation - Edit Automation - - - `; - } - - function listAutomations() { - const AutomationsList = document.getElementById("automations-list"); - fetch('/api/automations') - .then(response => response.json()) - .then(automations => { - if (!automations?.length > 0) return; - AutomationsList.innerHTML = automations.map(generateAutomationRow).join(""); - }); - } - - async function createAutomation() { - const scheduling_request = window.prompt("Describe the automation you want to create"); - if (!scheduling_request) return; - - const ip_response = await fetch("https://ipapi.co/json"); - const ip_data = await ip_response.json(); - - const query_string = `q=${scheduling_request}&city=${ip_data.city}®ion=${ip_data.region}&country=${ip_data.country_name}&timezone=${ip_data.timezone}`; - const automation_response = await fetch(`/api/automation?${query_string}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }); - if (!automation_response.ok) { - throw new Error(`Failed to create automation: ${automation_response.status}`); - } - - listAutomations(); - } - document.getElementById("create-automation").addEventListener("click", async () => { await createAutomation(); }); - - function editAutomation(automationId) { - const query_to_run = window.prompt("What is the query you want to run on this automation's schedule?"); - if (!query_to_run) return; - - fetch(`/api/automation?automation_id=${automationId}&query_to_run=${query_to_run}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - }).then(response => { - if (response.ok) { - const automationQueryToRunColumn = document.getElementById(`automation-query-to-run-${automationId}`); - automationQueryToRunColumn.innerHTML = `${query_to_run}`; - } - }); - } - function getIndexedDataSize() { document.getElementById("indexed-data-size").innerHTML = "Calculating..."; fetch('/api/config/index/size') @@ -723,9 +644,6 @@ }); } - // List user's automations on page load - listAutomations(); - function removeFile(path) { fetch('/api/config/data/file?filename=' + path, { method: 'DELETE', diff --git a/src/khoj/interface/web/config_automation.html b/src/khoj/interface/web/config_automation.html index d307a463..d8089053 100644 --- a/src/khoj/interface/web/config_automation.html +++ b/src/khoj/interface/web/config_automation.html @@ -6,17 +6,29 @@ Automate Automate (Preview)
- You can automate queries to run on a schedule using Khoj's automations for smart reminders. Results will be sent straight to your inbox. This is an experimental feature, so your results may vary. Report any issues to team@khoj.dev. + Automations allow you to schedule smart reminders using Khoj. This is an experimental feature, so your results may vary! Send any feedback to team@khoj.dev. +
+
+ Sending automation results to {{ username }}.
+
+

+ Suggested Automations +

+
+
+ {% endblock %} diff --git a/src/khoj/interface/web/login.html b/src/khoj/interface/web/login.html index 91ef6007..6443e6a4 100644 --- a/src/khoj/interface/web/login.html +++ b/src/khoj/interface/web/login.html @@ -16,7 +16,7 @@
- + - -{% endblock %} diff --git a/src/khoj/interface/web/public_conversation.html b/src/khoj/interface/web/public_conversation.html new file mode 100644 index 00000000..afade059 --- /dev/null +++ b/src/khoj/interface/web/public_conversation.html @@ -0,0 +1,1916 @@ + + + + + + Khoj: {{ public_conversation_slug | replace("-", " ")}} + + + + + + + + + + + + + + + + + +
+
+ + + + + + {% import 'utils.html' as utils %} + {{ utils.heading_pane(user_photo, username, is_active, has_documents) }} +
+
+
+
+
Agents
+ +
+
+
+ {% for agent in agents %} + + + + {% endfor %} +
+
+ + + +
+
+ +
+
+
+ +
+ + + +
+
+ + + + + diff --git a/src/khoj/interface/web/utils.html b/src/khoj/interface/web/utils.html index b2d719cb..536c01d4 100644 --- a/src/khoj/interface/web/utils.html +++ b/src/khoj/interface/web/utils.html @@ -37,6 +37,7 @@
{{ username }}
Settings + GitHub Help Logout
diff --git a/src/khoj/main.py b/src/khoj/main.py index 40b449d7..64cfc194 100644 --- a/src/khoj/main.py +++ b/src/khoj/main.py @@ -96,7 +96,7 @@ from khoj.utils.initialization import initialization def shutdown_scheduler(): logger.info("🌑 Shutting down Khoj") - state.scheduler.shutdown() + # state.scheduler.shutdown() def run(should_start_server=True): diff --git a/src/khoj/processor/conversation/anthropic/__init__.py b/src/khoj/processor/conversation/anthropic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/khoj/processor/conversation/anthropic/anthropic_chat.py b/src/khoj/processor/conversation/anthropic/anthropic_chat.py new file mode 100644 index 00000000..8c6157b8 --- /dev/null +++ b/src/khoj/processor/conversation/anthropic/anthropic_chat.py @@ -0,0 +1,202 @@ +import json +import logging +import re +from datetime import datetime, timedelta +from typing import Dict, Optional + +from langchain.schema import ChatMessage + +from khoj.database.models import Agent +from khoj.processor.conversation import prompts +from khoj.processor.conversation.anthropic.utils import ( + anthropic_chat_completion_with_backoff, + anthropic_completion_with_backoff, +) +from khoj.processor.conversation.utils import generate_chatml_messages_with_context +from khoj.utils.helpers import ConversationCommand, is_none_or_empty +from khoj.utils.rawconfig import LocationData + +logger = logging.getLogger(__name__) + + +def extract_questions_anthropic( + text, + model: Optional[str] = "claude-instant-1.2", + conversation_log={}, + api_key=None, + temperature=0, + location_data: LocationData = None, +): + """ + Infer search queries to retrieve relevant notes to answer user query + """ + # Extract Past User Message and Inferred Questions from Conversation Log + location = f"{location_data.city}, {location_data.region}, {location_data.country}" if location_data else "Unknown" + + # Extract Past User Message and Inferred Questions from Conversation Log + chat_history = "".join( + [ + f'Q: {chat["intent"]["query"]}\nKhoj: {{"queries": {chat["intent"].get("inferred-queries") or list([chat["intent"]["query"]])}}}\nA: {chat["message"]}\n\n' + for chat in conversation_log.get("chat", [])[-4:] + if chat["by"] == "khoj" and "text-to-image" not in chat["intent"].get("type") + ] + ) + + # Get dates relative to today for prompt creation + today = datetime.today() + current_new_year = today.replace(month=1, day=1) + last_new_year = current_new_year.replace(year=today.year - 1) + + system_prompt = prompts.extract_questions_anthropic_system_prompt.format( + current_date=today.strftime("%Y-%m-%d"), + day_of_week=today.strftime("%A"), + last_new_year=last_new_year.strftime("%Y"), + last_new_year_date=last_new_year.strftime("%Y-%m-%d"), + current_new_year_date=current_new_year.strftime("%Y-%m-%d"), + yesterday_date=(today - timedelta(days=1)).strftime("%Y-%m-%d"), + location=location, + ) + + prompt = prompts.extract_questions_anthropic_user_message.format( + chat_history=chat_history, + text=text, + ) + + messages = [ChatMessage(content=prompt, role="user")] + + response = anthropic_completion_with_backoff( + messages=messages, + system_prompt=system_prompt, + model_name=model, + temperature=temperature, + api_key=api_key, + ) + + # Extract, Clean Message from Claude's Response + try: + response = response.strip() + match = re.search(r"\{.*?\}", response) + if match: + response = match.group() + response = json.loads(response) + response = [q.strip() for q in response["queries"] if q.strip()] + if not isinstance(response, list) or not response: + logger.error(f"Invalid response for constructing subqueries: {response}") + return [text] + return response + except: + logger.warning(f"Claude returned invalid JSON. Falling back to using user message as search query.\n{response}") + questions = [text] + logger.debug(f"Extracted Questions by Claude: {questions}") + return questions + + +def anthropic_send_message_to_model(messages, api_key, model): + """ + Send message to model + """ + # Anthropic requires the first message to be a 'user' message, and the system prompt is not to be sent in the messages parameter + system_prompt = None + + if len(messages) == 1: + messages[0].role = "user" + else: + system_prompt = "" + for message in messages.copy(): + if message.role == "system": + system_prompt += message.content + messages.remove(message) + + # Get Response from GPT. Don't use response_type because Anthropic doesn't support it. + return anthropic_completion_with_backoff( + messages=messages, + system_prompt=system_prompt, + model_name=model, + api_key=api_key, + ) + + +def converse_anthropic( + references, + user_query, + online_results: Optional[Dict[str, Dict]] = None, + conversation_log={}, + model: Optional[str] = "claude-instant-1.2", + api_key: Optional[str] = None, + completion_func=None, + conversation_commands=[ConversationCommand.Default], + max_prompt_size=None, + tokenizer_name=None, + location_data: LocationData = None, + user_name: str = None, + agent: Agent = None, +): + """ + Converse with user using Anthropic's Claude + """ + # Initialize Variables + current_date = datetime.now().strftime("%Y-%m-%d") + compiled_references = "\n\n".join({f"# {item}" for item in references}) + + conversation_primer = prompts.query_prompt.format(query=user_query) + + if agent and agent.personality: + system_prompt = prompts.custom_personality.format( + name=agent.name, bio=agent.personality, current_date=current_date + ) + else: + system_prompt = prompts.personality.format(current_date=current_date) + + if location_data: + location = f"{location_data.city}, {location_data.region}, {location_data.country}" + location_prompt = prompts.user_location.format(location=location) + system_prompt = f"{system_prompt}\n{location_prompt}" + + if user_name: + user_name_prompt = prompts.user_name.format(name=user_name) + system_prompt = f"{system_prompt}\n{user_name_prompt}" + + # Get Conversation Primer appropriate to Conversation Type + if conversation_commands == [ConversationCommand.Notes] and is_none_or_empty(compiled_references): + completion_func(chat_response=prompts.no_notes_found.format()) + return iter([prompts.no_notes_found.format()]) + elif conversation_commands == [ConversationCommand.Online] and is_none_or_empty(online_results): + completion_func(chat_response=prompts.no_online_results_found.format()) + return iter([prompts.no_online_results_found.format()]) + + if ConversationCommand.Online in conversation_commands or ConversationCommand.Webpage in conversation_commands: + conversation_primer = ( + f"{prompts.online_search_conversation.format(online_results=str(online_results))}\n{conversation_primer}" + ) + if not is_none_or_empty(compiled_references): + conversation_primer = f"{prompts.notes_conversation.format(query=user_query, references=compiled_references)}\n\n{conversation_primer}" + + # Setup Prompt with Primer or Conversation History + messages = generate_chatml_messages_with_context( + conversation_primer, + conversation_log=conversation_log, + model_name=model, + max_prompt_size=max_prompt_size, + tokenizer_name=tokenizer_name, + ) + + for message in messages.copy(): + if message.role == "system": + system_prompt += message.content + messages.remove(message) + + truncated_messages = "\n".join({f"{message.content[:40]}..." for message in messages}) + logger.debug(f"Conversation Context for Claude: {truncated_messages}") + + # Get Response from Claude + return anthropic_chat_completion_with_backoff( + messages=messages, + compiled_references=references, + online_results=online_results, + model_name=model, + temperature=0, + api_key=api_key, + system_prompt=system_prompt, + completion_func=completion_func, + max_prompt_size=max_prompt_size, + ) diff --git a/src/khoj/processor/conversation/anthropic/utils.py b/src/khoj/processor/conversation/anthropic/utils.py new file mode 100644 index 00000000..d6c8f4f2 --- /dev/null +++ b/src/khoj/processor/conversation/anthropic/utils.py @@ -0,0 +1,116 @@ +import logging +from threading import Thread +from typing import Dict, List + +import anthropic +from tenacity import ( + before_sleep_log, + retry, + stop_after_attempt, + wait_exponential, + wait_random_exponential, +) + +from khoj.processor.conversation.utils import ThreadedGenerator + +logger = logging.getLogger(__name__) + +anthropic_clients: Dict[str, anthropic.Anthropic] = {} + + +DEFAULT_MAX_TOKENS_ANTHROPIC = 3000 + + +@retry( + wait=wait_random_exponential(min=1, max=10), + stop=stop_after_attempt(2), + before_sleep=before_sleep_log(logger, logging.DEBUG), + reraise=True, +) +def anthropic_completion_with_backoff( + messages, system_prompt, model_name, temperature=0, api_key=None, model_kwargs=None, max_tokens=None +) -> str: + if api_key not in anthropic_clients: + client: anthropic.Anthropic = anthropic.Anthropic(api_key=api_key) + anthropic_clients[api_key] = client + else: + client = anthropic_clients[api_key] + + formatted_messages = [{"role": message.role, "content": message.content} for message in messages] + + aggregated_response = "" + max_tokens = max_tokens or DEFAULT_MAX_TOKENS_ANTHROPIC + + model_kwargs = model_kwargs or dict() + if system_prompt: + model_kwargs["system"] = system_prompt + + with client.messages.stream( + messages=formatted_messages, + model=model_name, # type: ignore + temperature=temperature, + timeout=20, + max_tokens=max_tokens, + **(model_kwargs), + ) as stream: + for text in stream.text_stream: + aggregated_response += text + + return aggregated_response + + +@retry( + wait=wait_exponential(multiplier=1, min=4, max=10), + stop=stop_after_attempt(2), + before_sleep=before_sleep_log(logger, logging.DEBUG), + reraise=True, +) +def anthropic_chat_completion_with_backoff( + messages, + compiled_references, + online_results, + model_name, + temperature, + api_key, + system_prompt, + max_prompt_size=None, + completion_func=None, + model_kwargs=None, +): + g = ThreadedGenerator(compiled_references, online_results, completion_func=completion_func) + t = Thread( + target=anthropic_llm_thread, + args=(g, messages, system_prompt, model_name, temperature, api_key, max_prompt_size, model_kwargs), + ) + t.start() + return g + + +def anthropic_llm_thread( + g, messages, system_prompt, model_name, temperature, api_key, max_prompt_size=None, model_kwargs=None +): + if api_key not in anthropic_clients: + client: anthropic.Anthropic = anthropic.Anthropic(api_key=api_key) + anthropic_clients[api_key] = client + else: + client: anthropic.Anthropic = anthropic_clients[api_key] + + formatted_messages: List[anthropic.types.MessageParam] = [ + anthropic.types.MessageParam(role=message.role, content=message.content) for message in messages + ] + + max_prompt_size = max_prompt_size or DEFAULT_MAX_TOKENS_ANTHROPIC + + with client.messages.stream( + messages=formatted_messages, + model=model_name, # type: ignore + temperature=temperature, + system=system_prompt, + timeout=20, + max_tokens=max_prompt_size, + **(model_kwargs or dict()), + ) as stream: + for text in stream.text_stream: + g.send(text) + + g.close() diff --git a/src/khoj/processor/conversation/openai/gpt.py b/src/khoj/processor/conversation/openai/gpt.py index 8360a32e..e424a5cb 100644 --- a/src/khoj/processor/conversation/openai/gpt.py +++ b/src/khoj/processor/conversation/openai/gpt.py @@ -67,7 +67,6 @@ def extract_questions( messages=messages, model=model, temperature=temperature, - max_tokens=max_tokens, api_base_url=api_base_url, model_kwargs={"response_format": {"type": "json_object"}}, openai_api_key=api_key, diff --git a/src/khoj/processor/conversation/openai/utils.py b/src/khoj/processor/conversation/openai/utils.py index 0c37ba53..49f1d6b6 100644 --- a/src/khoj/processor/conversation/openai/utils.py +++ b/src/khoj/processor/conversation/openai/utils.py @@ -34,7 +34,7 @@ openai_clients: Dict[str, openai.OpenAI] = {} reraise=True, ) def completion_with_backoff( - messages, model, temperature=0, openai_api_key=None, api_base_url=None, model_kwargs=None, max_tokens=None + messages, model, temperature=0, openai_api_key=None, api_base_url=None, model_kwargs=None ) -> str: client_key = f"{openai_api_key}--{api_base_url}" client: openai.OpenAI = openai_clients.get(client_key) @@ -53,7 +53,6 @@ def completion_with_backoff( model=model, # type: ignore temperature=temperature, timeout=20, - max_tokens=max_tokens, **(model_kwargs or dict()), ) aggregated_response = "" diff --git a/src/khoj/processor/conversation/prompts.py b/src/khoj/processor/conversation/prompts.py index f3b65e15..d0f5356d 100644 --- a/src/khoj/processor/conversation/prompts.py +++ b/src/khoj/processor/conversation/prompts.py @@ -261,6 +261,45 @@ Khoj: """.strip() ) +extract_questions_anthropic_system_prompt = PromptTemplate.from_template( + """ +You are Khoj, an extremely smart and helpful document search assistant with only the ability to retrieve information from the user's notes. Disregard online search requests. Construct search queries to retrieve relevant information to answer the user's question. +- You will be provided past questions(Q) and answers(A) for context. +- Add as much context from the previous questions and answers as required into your search queries. +- Break messages into multiple search queries when required to retrieve the relevant information. +- Add date filters to your search queries from questions and answers when required to retrieve the relevant information. + +What searches will you perform to answer the users question? Respond with a JSON object with the key "queries" mapping to a list of searches you would perform on the user's knowledge base. Just return the queries and nothing else. + +Current Date: {day_of_week}, {current_date} +User's Location: {location} + +Here are some examples of how you can construct search queries to answer the user's question: + +User: How was my trip to Cambodia? +Assistant: {{"queries": ["How was my trip to Cambodia?"]}} + +User: What national parks did I go to last year? +Assistant: {{"queries": ["National park I visited in {last_new_year} dt>='{last_new_year_date}' dt<'{current_new_year_date}'"]}} + +User: How can you help me? +Assistant: {{"queries": ["Social relationships", "Physical and mental health", "Education and career", "Personal life goals and habits"]}} + +User: Who all did I meet here yesterday? +Assistant: {{"queries": ["Met in {location} on {yesterday_date} dt>='{yesterday_date}' dt<'{current_date}'"]}} +""".strip() +) + +extract_questions_anthropic_user_message = PromptTemplate.from_template( + """ +Here's our most recent chat history: +{chat_history} + +User: {text} +Assistant: +""".strip() +) + system_prompt_extract_relevant_information = """As a professional analyst, create a comprehensive report of the most relevant information from a web page in response to a user's query. The text provided is directly from within the web page. The report you create should be multiple paragraphs, and it should represent the content of the website. Tell the user exactly what the website says in response to their query, while adhering to these guidelines: 1. Answer the user's query as specifically as possible. Include many supporting details from the website. @@ -454,7 +493,7 @@ You are Khoj, an advanced google search assistant. You are tasked with construct - Break messages into multiple search queries when required to retrieve the relevant information. - Use site: google search operators when appropriate - You have access to the the whole internet to retrieve information. -- Official, up-to-date information about you, Khoj, is available at site:khoj.dev, github or pypi. +- Official, up-to-date information about you, Khoj, is available at site:khoj.dev What Google searches, if any, will you need to perform to answer the user's question? Provide search queries as a list of strings in a JSON object. @@ -510,6 +549,7 @@ Q: How many oranges would fit in NASA's Saturn V rocket? Khoj: {{"queries": ["volume of an orange", "volume of saturn v rocket"]}} Now it's your turn to construct Google search queries to answer the user's question. Provide them as a list of strings in a JSON object. Do not say anything else. +Now it's your turn to construct a search query for Google to answer the user's question. History: {chat_history} diff --git a/src/khoj/routers/api.py b/src/khoj/routers/api.py index 067d18b3..0188c40a 100644 --- a/src/khoj/routers/api.py +++ b/src/khoj/routers/api.py @@ -3,8 +3,10 @@ import json import logging import math import os +import threading import time import uuid +from random import random from typing import Any, Callable, List, Optional, Union import cron_descriptor @@ -26,6 +28,9 @@ from khoj.database.adapters import ( get_user_search_model_or_default, ) from khoj.database.models import ChatModelOptions, KhojUser, SpeechToTextModelOptions +from khoj.processor.conversation.anthropic.anthropic_chat import ( + extract_questions_anthropic, +) from khoj.processor.conversation.offline.chat_model import extract_questions_offline from khoj.processor.conversation.offline.whisper import transcribe_audio_offline from khoj.processor.conversation.openai.gpt import extract_questions @@ -338,6 +343,17 @@ async def extract_references_and_questions( api_key=api_key, conversation_log=meta_log, location_data=location_data, + max_tokens=conversation_config.max_prompt_size, + ) + elif conversation_config.model_type == ChatModelOptions.ModelType.ANTHROPIC: + api_key = conversation_config.openai_config.api_key + chat_model = conversation_config.chat_model + inferred_queries = extract_questions_anthropic( + defiltered_query, + model=chat_model, + api_key=api_key, + conversation_log=meta_log, + location_data=location_data, ) # Collate search results as context for GPT @@ -454,14 +470,28 @@ async def post_automation( crontime = " ".join(crontime.split(" ")[:5]) # Convert crontime to standard unix crontime crontime = crontime.replace("?", "*") + + # Disallow minute level automation recurrence + minute_value = crontime.split(" ")[0] + if not minute_value.isdigit(): + return Response( + content="Recurrence of every X minutes is unsupported. Please create a less frequent schedule.", + status_code=400, + ) + 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 try: # 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: - logger.error(f"Error creating automation {q} for {user.email}: {e}") + logger.error(f"Error creating automation {q} for {user.email}: {e}", exc_info=True) return Response( content=f"Unable to create automation. Ensure the automation doesn't already exist.", media_type="text/plain", @@ -475,6 +505,31 @@ async def post_automation( 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) @requires(["authenticated"]) def edit_job( @@ -515,6 +570,14 @@ def edit_job( # Convert crontime to standard unix crontime crontime = crontime.replace("?", "*") + # Disallow minute level automation recurrence + minute_value = crontime.split(" ")[0] + if not minute_value.isdigit(): + return Response( + content="Recurrence of every X minutes is unsupported. Please create a less frequent schedule.", + status_code=400, + ) + # Construct updated automation metadata automation_metadata = json.loads(automation.name) automation_metadata["scheduling_request"] = q diff --git a/src/khoj/routers/api_chat.py b/src/khoj/routers/api_chat.py index 30534b13..c428502f 100644 --- a/src/khoj/routers/api_chat.py +++ b/src/khoj/routers/api_chat.py @@ -13,7 +13,12 @@ from starlette.authentication import requires from starlette.websockets import WebSocketDisconnect from websockets import ConnectionClosedOK -from khoj.database.adapters import ConversationAdapters, EntryAdapters, aget_user_name +from khoj.database.adapters import ( + ConversationAdapters, + EntryAdapters, + PublicConversationAdapters, + aget_user_name, +) from khoj.database.models import KhojUser from khoj.processor.conversation.prompts import ( help_message, @@ -38,6 +43,7 @@ from khoj.routers.helpers import ( construct_automation_created_message, create_automation, get_conversation_command, + is_query_empty, is_ready_to_chat, text_to_image, update_telemetry_state, @@ -62,6 +68,23 @@ conversation_command_rate_limiter = ConversationCommandRateLimiter( api_chat = APIRouter() +from pydantic import BaseModel + +from khoj.routers.email import send_query_feedback + + +class FeedbackData(BaseModel): + uquery: str + kquery: str + sentiment: str + + +@api_chat.post("/feedback") +@requires(["authenticated"]) +async def sendfeedback(request: Request, data: FeedbackData): + user: KhojUser = request.user.object + await send_query_feedback(data.uquery, data.kquery, data.sentiment, user.email) + @api_chat.get("/starters", response_class=Response) @requires(["authenticated"]) @@ -132,6 +155,60 @@ def chat_history( return {"status": "ok", "response": meta_log} +@api_chat.get("/share/history") +def get_shared_chat( + request: Request, + common: CommonQueryParams, + public_conversation_slug: str, + n: Optional[int] = None, +): + user = request.user.object if request.user.is_authenticated else None + + # Load Conversation History + conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug) + + if conversation is None: + return Response( + content=json.dumps({"status": "error", "message": f"Conversation: {public_conversation_slug} not found"}), + status_code=404, + ) + + agent_metadata = None + if conversation.agent: + agent_metadata = { + "slug": conversation.agent.slug, + "name": conversation.agent.name, + "avatar": conversation.agent.avatar, + "isCreator": conversation.agent.creator == user, + } + + meta_log = conversation.conversation_log + meta_log.update( + { + "conversation_id": conversation.id, + "slug": conversation.title if conversation.title else conversation.slug, + "agent": agent_metadata, + } + ) + + if n: + # Get latest N messages if N > 0 + if n > 0 and meta_log.get("chat"): + meta_log["chat"] = meta_log["chat"][-n:] + # Else return all messages except latest N + elif n < 0 and meta_log.get("chat"): + meta_log["chat"] = meta_log["chat"][:n] + + update_telemetry_state( + request=request, + telemetry_type="api", + api="public_conversation_history", + **common.__dict__, + ) + + return {"status": "ok", "response": meta_log} + + @api_chat.delete("/history") @requires(["authenticated"]) async def clear_chat_history( @@ -154,6 +231,69 @@ async def clear_chat_history( return {"status": "ok", "message": "Conversation history cleared"} +@api_chat.post("/share/fork") +@requires(["authenticated"]) +def fork_public_conversation( + request: Request, + common: CommonQueryParams, + public_conversation_slug: str, +): + user = request.user.object + + # Load Conversation History + public_conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug) + + # Duplicate Public Conversation to User's Private Conversation + ConversationAdapters.create_conversation_from_public_conversation( + user, public_conversation, request.user.client_app + ) + + chat_metadata = {"forked_conversation": public_conversation.slug} + + update_telemetry_state( + request=request, + telemetry_type="api", + api="fork_public_conversation", + **common.__dict__, + metadata=chat_metadata, + ) + + redirect_uri = str(request.app.url_path_for("chat_page")) + + return Response(status_code=200, content=json.dumps({"status": "ok", "next_url": redirect_uri})) + + +@api_chat.post("/share") +@requires(["authenticated"]) +def duplicate_chat_history_public_conversation( + request: Request, + common: CommonQueryParams, + conversation_id: int, +): + user = request.user.object + + # Duplicate Conversation History to Public Conversation + conversation = ConversationAdapters.get_conversation_by_user(user, request.user.client_app, conversation_id) + + public_conversation = ConversationAdapters.make_public_conversation_copy(conversation) + + public_conversation_url = PublicConversationAdapters.get_public_conversation_url(public_conversation) + + domain = request.headers.get("host") + scheme = request.url.scheme + + update_telemetry_state( + request=request, + telemetry_type="api", + api="post_chat_share", + **common.__dict__, + ) + + return Response( + status_code=200, content=json.dumps({"status": "ok", "url": f"{scheme}://{domain}{public_conversation_url}"}) + ) + + @api_chat.get("/sessions") @requires(["authenticated"]) def chat_sessions( @@ -347,6 +487,10 @@ async def websocket_endpoint( if conversation: await sync_to_async(conversation.refresh_from_db)(fields=["conversation_log"]) q = await websocket.receive_text() + + # Refresh these because the connection to the database might have been closed + await conversation.arefresh_from_db() + except WebSocketDisconnect: logger.debug(f"User {user} disconnected web socket") break @@ -358,6 +502,14 @@ async def websocket_endpoint( await send_rate_limit_message(e.detail) break + if is_query_empty(q): + await send_message("start_llm_response") + await send_message( + "It seems like your query is incomplete. Could you please provide more details or specify what you need help with?" + ) + await send_message("end_llm_response") + continue + user_message_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conversation_commands = [get_conversation_command(query=q, any_references=True)] @@ -465,11 +617,17 @@ async def websocket_endpoint( if ConversationCommand.Webpage in conversation_commands: try: - online_results = await read_webpages(defiltered_query, meta_log, location, send_status_update) + direct_web_pages = await read_webpages(defiltered_query, meta_log, location, send_status_update) webpages = [] - for query in online_results: - for webpage in online_results[query]["webpages"]: + for query in direct_web_pages: + if online_results.get(query): + online_results[query]["webpages"] = direct_web_pages[query]["webpages"] + else: + online_results[query] = {"webpages": direct_web_pages[query]["webpages"]} + + for webpage in direct_web_pages[query]["webpages"]: webpages.append(webpage["link"]) + await send_status_update(f"**📚 Read web pages**: {webpages}") except ValueError as e: logger.warning( @@ -585,6 +743,12 @@ async def chat( ) -> Response: user: KhojUser = request.user.object q = unquote(q) + if is_query_empty(q): + return Response( + content="It seems like your query is incomplete. Could you please provide more details or specify what you need help with?", + media_type="text/plain", + status_code=400, + ) user_message_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") logger.info(f"Chat request by {user.username}: {q}") @@ -634,7 +798,7 @@ async def chat( q, timezone, user, request.url, meta_log ) except Exception as e: - logger.error(f"Error creating automation {q} for {user.email}: {e}") + logger.error(f"Error creating automation {q} for {user.email}: {e}", exc_info=True) return Response( content=f"Unable to create automation. Ensure the automation doesn't already exist.", media_type="text/plain", diff --git a/src/khoj/routers/email.py b/src/khoj/routers/email.py index b1eb418e..ff5cb1ce 100644 --- a/src/khoj/routers/email.py +++ b/src/khoj/routers/email.py @@ -42,7 +42,7 @@ async def send_welcome_email(name, email): r = resend.Emails.send( { - "from": "team@khoj.dev", + "sender": "team@khoj.dev", "to": email, "subject": f"{name}, four ways to use Khoj" if name else "Four ways to use Khoj", "html": html_content, @@ -50,6 +50,33 @@ async def send_welcome_email(name, email): ) +async def send_query_feedback(uquery, kquery, sentiment, user_email): + if not is_resend_enabled(): + logger.debug(f"Sentiment: {sentiment}, Query: {uquery}, Khoj Response: {kquery}") + return + + logger.info(f"Sending feedback email for query {uquery}") + + # rendering feedback email using feedback.html as template + template = env.get_template("feedback.html") + html_content = template.render( + uquery=uquery if not is_none_or_empty(uquery) else "N/A", + kquery=kquery if not is_none_or_empty(kquery) else "N/A", + sentiment=sentiment if not is_none_or_empty(sentiment) else "N/A", + user_email=user_email if not is_none_or_empty(user_email) else "N/A", + ) + # send feedback from two fixed accounts + r = resend.Emails.send( + { + "sender": "saba@khoj.dev", + "to": "team@khoj.dev", + "subject": f"User Feedback", + "html": html_content, + } + ) + return {"message": "Sent Email"} + + def send_task_email(name, email, query, result, subject): if not is_resend_enabled(): logger.debug("Email sending disabled") @@ -64,7 +91,7 @@ def send_task_email(name, email, query, result, subject): r = resend.Emails.send( { - "from": "Khoj ", + "sender": "Khoj ", "to": email, "subject": f"✨ {subject}", "html": html_content, diff --git a/src/khoj/routers/helpers.py b/src/khoj/routers/helpers.py index 85852331..d8102517 100644 --- a/src/khoj/routers/helpers.py +++ b/src/khoj/routers/helpers.py @@ -4,10 +4,12 @@ import hashlib import io import json import logging +import math import re from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from functools import partial +from random import random from typing import ( Annotated, Any, @@ -53,6 +55,10 @@ from khoj.database.models import ( UserRequests, ) from khoj.processor.conversation import prompts +from khoj.processor.conversation.anthropic.anthropic_chat import ( + anthropic_send_message_to_model, + converse_anthropic, +) from khoj.processor.conversation.offline.chat_model import ( converse_offline, send_message_to_model_offline, @@ -84,6 +90,10 @@ logger = logging.getLogger(__name__) executor = ThreadPoolExecutor(max_workers=1) +def is_query_empty(query: str) -> bool: + return is_none_or_empty(query.strip()) + + def validate_conversation_config(): default_config = ConversationAdapters.get_default_conversation_config() @@ -109,7 +119,7 @@ async def is_ready_to_chat(user: KhojUser): if ( user_conversation_config - and user_conversation_config.model_type == "openai" + and (user_conversation_config.model_type == "openai" or user_conversation_config.model_type == "anthropic") and user_conversation_config.openai_config ): return True @@ -388,9 +398,13 @@ async def extract_relevant_info(q: str, corpus: str) -> Union[str, None]: corpus=corpus.strip(), ) + summarizer_model: ChatModelOptions = await ConversationAdapters.aget_summarizer_conversation_config() + with timer("Chat actor: Extract relevant information from data", logger): response = await send_message_to_model_wrapper( - extract_relevant_information, prompts.system_prompt_extract_relevant_information + extract_relevant_information, + prompts.system_prompt_extract_relevant_information, + chat_model_option=summarizer_model, ) return response.strip() @@ -445,8 +459,11 @@ async def send_message_to_model_wrapper( message: str, system_message: str = "", response_type: str = "text", + chat_model_option: ChatModelOptions = None, ): - conversation_config: ChatModelOptions = await ConversationAdapters.aget_default_conversation_config() + conversation_config: ChatModelOptions = ( + chat_model_option or await ConversationAdapters.aget_default_conversation_config() + ) if conversation_config is None: raise HTTPException(status_code=500, detail="Contact the server administrator to set a default chat model.") @@ -497,6 +514,21 @@ async def send_message_to_model_wrapper( ) return openai_response + elif conversation_config.model_type == "anthropic": + api_key = conversation_config.openai_config.api_key + truncated_messages = generate_chatml_messages_with_context( + user_message=message, + system_message=system_message, + model_name=chat_model, + max_prompt_size=max_tokens, + tokenizer_name=tokenizer, + ) + + return anthropic_send_message_to_model( + messages=truncated_messages, + api_key=api_key, + model=chat_model, + ) else: raise HTTPException(status_code=500, detail="Invalid conversation config") @@ -531,8 +563,7 @@ def send_message_to_model_wrapper_sync( ) elif conversation_config.model_type == "openai": - openai_chat_config = ConversationAdapters.get_openai_conversation_config() - api_key = openai_chat_config.api_key + api_key = conversation_config.openai_config.api_key truncated_messages = generate_chatml_messages_with_context( user_message=message, system_message=system_message, model_name=chat_model ) @@ -542,6 +573,21 @@ def send_message_to_model_wrapper_sync( ) return openai_response + + elif conversation_config.model_type == "anthropic": + api_key = conversation_config.openai_config.api_key + truncated_messages = generate_chatml_messages_with_context( + user_message=message, + system_message=system_message, + model_name=chat_model, + max_prompt_size=max_tokens, + ) + + return anthropic_send_message_to_model( + messages=truncated_messages, + api_key=api_key, + model=chat_model, + ) else: raise HTTPException(status_code=500, detail="Invalid conversation config") @@ -620,6 +666,24 @@ def generate_chat_response( agent=agent, ) + elif conversation_config.model_type == "anthropic": + api_key = conversation_config.openai_config.api_key + chat_response = converse_anthropic( + compiled_references, + q, + online_results, + meta_log, + model=conversation_config.chat_model, + api_key=api_key, + completion_func=partial_completion, + conversation_commands=conversation_commands, + max_prompt_size=conversation_config.max_prompt_size, + tokenizer_name=conversation_config.tokenizer, + location_data=location_data, + user_name=user_name, + agent=agent, + ) + metadata.update({"chat_model": conversation_config.chat_model}) except Exception as e: @@ -931,7 +995,7 @@ def scheduled_chat( # Stop if the chat API call was not successful if raw_response.status_code != 200: - logger.error(f"Failed to run schedule chat: {raw_response.text}") + logger.error(f"Failed to run schedule chat: {raw_response.text}, user: {user}, query: {query_to_run}") return None # Extract the AI response from the chat API response @@ -965,6 +1029,12 @@ async def schedule_automation( user: KhojUser, calling_url: URL, ): + # Disable minute level automation recurrence + minute_value = crontime.split(" ")[0] + if not minute_value.isdigit(): + # Run automation at some random minute (to distribute request load) instead of running every X minutes + crontime = " ".join([str(math.floor(random() * 60))] + crontime.split(" ")[1:]) + user_timezone = pytz.timezone(timezone) trigger = CronTrigger.from_crontab(crontime, user_timezone) trigger.jitter = 60 diff --git a/src/khoj/routers/web_client.py b/src/khoj/routers/web_client.py index 047273e9..ec3606fc 100644 --- a/src/khoj/routers/web_client.py +++ b/src/khoj/routers/web_client.py @@ -11,9 +11,9 @@ from starlette.authentication import has_required_scope, requires from khoj.database import adapters from khoj.database.adapters import ( AgentAdapters, - AutomationAdapters, ConversationAdapters, EntryAdapters, + PublicConversationAdapters, get_user_github_config, get_user_name, get_user_notion_config, @@ -350,9 +350,9 @@ def notion_config_page(request: Request): @web_client.get("/config/content-source/computer", response_class=HTMLResponse) @requires(["authenticated"], redirect="login_page") def computer_config_page(request: Request): - user = request.user.object - user_picture = request.session.get("user", {}).get("picture") - has_documents = EntryAdapters.user_has_entries(user=user) + user = request.user.object if request.user.is_authenticated else None + user_picture = request.session.get("user", {}).get("picture") if user else None + has_documents = EntryAdapters.user_has_entries(user=user) if user else False return templates.TemplateResponse( "content_source_computer_input.html", @@ -367,6 +367,59 @@ def computer_config_page(request: Request): ) +@web_client.get("/share/chat/{public_conversation_slug}", response_class=HTMLResponse) +def view_public_conversation(request: Request): + public_conversation_slug = request.path_params.get("public_conversation_slug") + public_conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug) + if not public_conversation: + return templates.TemplateResponse( + "404.html", + context={ + "request": request, + "khoj_version": state.khoj_version, + }, + ) + user = request.user.object if request.user.is_authenticated else None + user_picture = request.session.get("user", {}).get("picture") if user else None + has_documents = EntryAdapters.user_has_entries(user=user) if user else False + + all_agents = AgentAdapters.get_all_accessible_agents(request.user.object if request.user.is_authenticated else None) + + # Filter out the current agent + all_agents = [agent for agent in all_agents if agent != public_conversation.agent] + agents_packet = [] + for agent in all_agents: + agents_packet.append( + { + "slug": agent.slug, + "avatar": agent.avatar, + "name": agent.name, + } + ) + + google_client_id = os.environ.get("GOOGLE_CLIENT_ID") + redirect_uri = str(request.app.url_path_for("auth")) + next_url = str( + request.app.url_path_for("view_public_conversation", public_conversation_slug=public_conversation_slug) + ) + + return templates.TemplateResponse( + "public_conversation.html", + context={ + "request": request, + "username": user.username if user else None, + "user_photo": user_picture, + "is_active": has_required_scope(request, ["premium"]), + "has_documents": has_documents, + "khoj_version": state.khoj_version, + "public_conversation_slug": public_conversation_slug, + "agents": agents_packet, + "google_client_id": google_client_id, + "redirect_uri": f"{redirect_uri}?next={next_url}", + }, + ) + + @web_client.get("/automations", response_class=HTMLResponse) @requires(["authenticated"], redirect="login_page") def automations_config_page(request: Request): diff --git a/versions.json b/versions.json index 8aaaaeb1..d143e52c 100644 --- a/versions.json +++ b/versions.json @@ -48,5 +48,6 @@ "1.11.0": "0.15.0", "1.11.1": "0.15.0", "1.11.2": "0.15.0", - "1.12.0": "0.15.0" + "1.12.0": "0.15.0", + "1.12.1": "0.15.0" }