mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-02 21:19:12 +00:00
Merge branch 'master' of github.com:khoj-ai/khoj into HEAD
This commit is contained in:
2
.github/workflows/github_pages_deploy.yml
vendored
2
.github/workflows/github_pages_deploy.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: build and deploy github pages for documentation
|
||||
name: deploy documentation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
container: ubuntu:jammy
|
||||
container: ubuntu:latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -67,19 +67,19 @@ jobs:
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run : |
|
||||
apt install -y postgresql postgresql-client && apt install -y postgresql-server-dev-14
|
||||
apt install -y postgresql postgresql-client && apt install -y postgresql-server-dev-16
|
||||
|
||||
- name: ⬇️ Install pip
|
||||
run: |
|
||||
apt install -y python3-pip
|
||||
python -m ensurepip --upgrade
|
||||
python -m pip install --upgrade pip
|
||||
python3 -m ensurepip --upgrade
|
||||
python3 -m pip install --upgrade pip
|
||||
|
||||
- name: ⬇️ Install Application
|
||||
env:
|
||||
PIP_EXTRA_INDEX_URL: "https://download.pytorch.org/whl/cpu https://abetlen.github.io/llama-cpp-python/whl/cpu"
|
||||
CUDA_VISIBLE_DEVICES: ""
|
||||
run: sed -i 's/dynamic = \["version"\]/version = "0.0.0"/' pyproject.toml && pip install --upgrade .[dev]
|
||||
run: sed -i 's/dynamic = \["version"\]/version = "0.0.0"/' pyproject.toml && pip install --break-system-packages --upgrade .[dev]
|
||||
|
||||
- name: 🧪 Test Application
|
||||
env:
|
||||
|
||||
@@ -58,6 +58,6 @@ RUN cd src && python3 khoj/manage.py collectstatic --noinput
|
||||
# Run the Application
|
||||
# There are more arguments required for the application to run,
|
||||
# but those should be passed in through the docker-compose.yml file.
|
||||
ARG PORT
|
||||
ARG PORT=42110
|
||||
EXPOSE ${PORT}
|
||||
ENTRYPOINT ["python3", "src/khoj/main.py"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
database:
|
||||
image: ankane/pgvector
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -15,10 +14,8 @@ services:
|
||||
retries: 5
|
||||
sandbox:
|
||||
image: ghcr.io/khoj-ai/terrarium:latest
|
||||
restart: unless-stopped
|
||||
search:
|
||||
image: docker.io/searxng/searxng:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- khoj_search:/etc/searxng
|
||||
environment:
|
||||
@@ -29,7 +26,6 @@ services:
|
||||
condition: service_healthy
|
||||
# Use the following line to use the latest version of khoj. Otherwise, it will build from source. Set this to ghcr.io/khoj-ai/khoj-cloud:latest if you want to use the prod image.
|
||||
image: ghcr.io/khoj-ai/khoj:latest
|
||||
restart: unless-stopped
|
||||
# Uncomment the following line to build from source. This will take a few minutes. Comment the next two lines out if you want to use the official image.
|
||||
# build:
|
||||
# context: .
|
||||
@@ -63,7 +59,7 @@ services:
|
||||
- KHOJ_SEARXNG_URL=http://search:8080
|
||||
# Uncomment line below to use with Ollama running on your local machine at localhost:11434.
|
||||
# Change URL to use with other OpenAI API compatible providers like VLLM, LMStudio etc.
|
||||
# - OPENAI_API_BASE=http://host.docker.internal:11434/v1/
|
||||
# - OPENAI_BASE_URL=http://host.docker.internal:11434/v1/
|
||||
#
|
||||
# Uncomment appropriate lines below to use chat models by OpenAI, Anthropic, Google.
|
||||
# Ensure you set your provider specific API keys.
|
||||
|
||||
@@ -14,14 +14,14 @@ LM Studio can expose an [OpenAI API compatible server](https://lmstudio.ai/docs/
|
||||
## Setup
|
||||
1. Install [LM Studio](https://lmstudio.ai/) and download your preferred Chat Model
|
||||
2. Go to the Server Tab on LM Studio, Select your preferred Chat Model and Click the green Start Server button
|
||||
3. Create a new [OpenAI Processor Conversation Config](http://localhost:42110/server/admin/database/openaiprocessorconversationconfig/add) on your Khoj admin panel
|
||||
3. Create a new [Add ai model api](http://localhost:42110/server/admin/database/aimodelapi/add/) on your Khoj admin panel
|
||||
- Name: `proxy-name`
|
||||
- Api Key: `any string`
|
||||
- Api Base Url: `http://localhost:1234/v1/` (default for LMStudio)
|
||||
4. Create a new [Chat Model](http://localhost:42110/server/admin/database/chatmodel/add) on your Khoj admin panel.
|
||||
- Name: `llama3.1` (replace with the name of your local model)
|
||||
- Model Type: `Openai`
|
||||
- Openai Config: `<the proxy config you created in step 3>`
|
||||
- Ai model api: `<the Ai model api you created in step 3>`
|
||||
- Max prompt size: `20000` (replace with the max prompt size of your model)
|
||||
- Tokenizer: *Do not set for OpenAI, mistral, llama3 based models*
|
||||
5. Go to [your config](http://localhost:42110/settings) and select the model you just created in the chat model dropdown.
|
||||
|
||||
@@ -32,7 +32,7 @@ Restart your Khoj server after first run or update to the settings below to ensu
|
||||
```bash
|
||||
ollama pull llama3.1
|
||||
```
|
||||
3. Uncomment `OPENAI_API_BASE` environment variable in your downloaded Khoj [docker-compose.yml](https://github.com/khoj-ai/khoj/blob/master/docker-compose.yml#:~:text=OPENAI_API_BASE)
|
||||
3. Uncomment `OPENAI_BASE_URL` environment variable in your downloaded Khoj [docker-compose.yml](https://github.com/khoj-ai/khoj/blob/master/docker-compose.yml#:~:text=OPENAI_BASE_URL)
|
||||
4. Start Khoj docker for the first time to automatically integrate and load models from the Ollama running on your host machine
|
||||
```bash
|
||||
# run below command in the directory where you downloaded the Khoj docker-compose.yml
|
||||
@@ -46,9 +46,9 @@ Restart your Khoj server after first run or update to the settings below to ensu
|
||||
```bash
|
||||
ollama pull llama3.1
|
||||
```
|
||||
3. Set `OPENAI_API_BASE` environment variable to `http://localhost:11434/v1/` in your shell before starting Khoj for the first time
|
||||
3. Set `OPENAI_BASE_URL` environment variable to `http://localhost:11434/v1/` in your shell before starting Khoj for the first time
|
||||
```bash
|
||||
export OPENAI_API_BASE="http://localhost:11434/v1/"
|
||||
export OPENAI_BASE_URL="http://localhost:11434/v1/"
|
||||
khoj --anonymous-mode
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
@@ -15,3 +15,37 @@ Take advantage of super fast search to find relevant notes and documents from yo
|
||||
|
||||
### Demo
|
||||

|
||||
|
||||
|
||||
### Implementation Overview
|
||||
A bi-encoder models is used to create meaning vectors (aka vector embeddings) of your documents and search queries.
|
||||
1. When you sync you documents with Khoj, it uses the bi-encoder model to create and store meaning vectors of (chunks of) your documents
|
||||
2. When you initiate a natural language search the bi-encoder model converts your query into a meaning vector and finds the most relevant document chunks for that query by comparing their meaning vectors.
|
||||
3. The slower but higher-quality cross-encoder model is than used to re-rank these documents for your given query.
|
||||
|
||||
### Setup (Self-Hosting)
|
||||
You are **not required** to configure the search model config when self-hosting. Khoj sets up decent default local search model config for general use.
|
||||
|
||||
You may want to configure this if you need better multi-lingual search, want to experiment with different, newer models or the default models do not work for your use-case.
|
||||
|
||||
You can use bi-encoder models downloaded locally [from Huggingface](https://huggingface.co/models?library=sentence-transformers), served via the [HuggingFace Inference API](https://endpoints.huggingface.co/), OpenAI API, Azure OpenAI API or any OpenAI Compatible API like Ollama, LiteLLM etc. Follow the steps below to configure your search model:
|
||||
|
||||
1. Open the [SearchModelConfig](http://localhost:42110/server/admin/database/searchmodelconfig/) page on your Khoj admin panel.
|
||||
2. Hit the Plus button to add a new model config or click the id of an existing model config to edit it.
|
||||
3. Set the `biencoder` field to the name of the bi-encoder model supported [locally](https://huggingface.co/models?library=sentence-transformers) or via the API you configure.
|
||||
4. Set the `Embeddings inference endpoint api key` to your OpenAI API key and `Embeddings inference endpoint type` to `OpenAI` to use an OpenAI embedding model.
|
||||
5. Also set the `Embeddings inference endpoint` to your Azure OpenAI or OpenAI compatible API URL to use the model via those APIs.
|
||||
6. Ensure the search model config you want to use is the **only one** that has `name` field set to `default`[^1].
|
||||
7. Save the search model configs and restart your Khoj server to start using your new, updated search config.
|
||||
|
||||
:::info
|
||||
You will need to re-index all your documents if you want to use a different bi-encoder model.
|
||||
:::
|
||||
|
||||
:::info
|
||||
You may need to tune the `Bi encoder confidence threshold` field for each bi-encoder to get appropriate number of documents for chat with your Knowledge base.
|
||||
|
||||
Confidence here is a normalized measure of semantic distance between your query and documents. The confidence threshold limits the documents returned to chat that fall within the distance specified in this field. It can take values between 0.0 (exact overlap) and 1.0 (no meaning overlap).
|
||||
:::
|
||||
|
||||
[^1]: Khoj uses the first search model config named `default` it finds on startup as the search model config for that session
|
||||
|
||||
@@ -48,7 +48,7 @@ Restart your Khoj server after the first run to ensure all settings are applied
|
||||
2. Configure the environment variables in the `docker-compose.yml`
|
||||
- Set `KHOJ_ADMIN_PASSWORD`, `KHOJ_DJANGO_SECRET_KEY` (and optionally the `KHOJ_ADMIN_EMAIL`) to something secure. This allows you to customize Khoj later via the admin panel.
|
||||
- Set `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY` to your API key if you want to use OpenAI, Anthropic or Gemini commercial chat models respectively.
|
||||
- Uncomment `OPENAI_API_BASE` to use [Ollama](/advanced/ollama?type=first-run&server=docker#setup) running on your host machine. Or set it to the URL of your OpenAI compatible API like vLLM or [LMStudio](/advanced/lmstudio).
|
||||
- Uncomment `OPENAI_BASE_URL` to use [Ollama](/advanced/ollama?type=first-run&server=docker#setup) running on your host machine. Or set it to the URL of your OpenAI compatible API like vLLM or [LMStudio](/advanced/lmstudio).
|
||||
3. Start Khoj by running the following command in the same directory as your docker-compose.yml file.
|
||||
```shell
|
||||
cd ~/.khoj
|
||||
@@ -74,7 +74,7 @@ Restart your Khoj server after the first run to ensure all settings are applied
|
||||
2. Configure the environment variables in the `docker-compose.yml`
|
||||
- Set `KHOJ_ADMIN_PASSWORD`, `KHOJ_DJANGO_SECRET_KEY` (and optionally the `KHOJ_ADMIN_EMAIL`) to something secure. This allows you to customize Khoj later via the admin panel.
|
||||
- Set `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY` to your API key if you want to use OpenAI, Anthropic or Gemini commercial chat models respectively.
|
||||
- Uncomment `OPENAI_API_BASE` to use [Ollama](/advanced/ollama) running on your host machine. Or set it to the URL of your OpenAI compatible API like vLLM or [LMStudio](/advanced/lmstudio).
|
||||
- Uncomment `OPENAI_BASE_URL` to use [Ollama](/advanced/ollama) running on your host machine. Or set it to the URL of your OpenAI compatible API like vLLM or [LMStudio](/advanced/lmstudio).
|
||||
3. Start Khoj by running the following command in the same directory as your docker-compose.yml file.
|
||||
```shell
|
||||
# Windows users should use their WSL2 terminal to run these commands
|
||||
@@ -96,7 +96,7 @@ Restart your Khoj server after the first run to ensure all settings are applied
|
||||
2. Configure the environment variables in the `docker-compose.yml`
|
||||
- Set `KHOJ_ADMIN_PASSWORD`, `KHOJ_DJANGO_SECRET_KEY` (and optionally the `KHOJ_ADMIN_EMAIL`) to something secure. This allows you to customize Khoj later via the admin panel.
|
||||
- Set `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY` to your API key if you want to use OpenAI, Anthropic or Gemini commercial chat models respectively.
|
||||
- Uncomment `OPENAI_API_BASE` to use [Ollama](/advanced/ollama) running on your host machine. Or set it to the URL of your OpenAI compatible API like vLLM or [LMStudio](/advanced/lmstudio).
|
||||
- Uncomment `OPENAI_BASE_URL` to use [Ollama](/advanced/ollama) running on your host machine. Or set it to the URL of your OpenAI compatible API like vLLM or [LMStudio](/advanced/lmstudio).
|
||||
3. Start Khoj by running the following command in the same directory as your docker-compose.yml file.
|
||||
```shell
|
||||
cd ~/.khoj
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "khoj",
|
||||
"name": "Khoj",
|
||||
"version": "1.33.2",
|
||||
"version": "1.34.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Your Second Brain",
|
||||
"author": "Khoj Inc.",
|
||||
|
||||
@@ -229,7 +229,7 @@ function generateImageMarkdown(message, intentType, inferredQueries=null) { //sa
|
||||
} else if (intentType === "text-to-image2") {
|
||||
imageMarkdown = ``;
|
||||
} else if (intentType === "text-to-image-v3") {
|
||||
imageMarkdown = ``;
|
||||
imageMarkdown = ``;
|
||||
}
|
||||
const inferredQuery = inferredQueries?.[0];
|
||||
if (inferredQuery) {
|
||||
@@ -423,7 +423,7 @@ function handleImageResponse(imageJson, rawResponse) {
|
||||
} else if (imageJson.intentType === "text-to-image2") {
|
||||
rawResponse += ``;
|
||||
} else if (imageJson.intentType === "text-to-image-v3") {
|
||||
rawResponse = ``;
|
||||
rawResponse = ``;
|
||||
} else if (imageJson.intentType === "excalidraw") {
|
||||
const redirectMessage = `Hey, I'm not ready to show you diagrams yet here. But you can view it in the web app`;
|
||||
rawResponse += redirectMessage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Khoj",
|
||||
"version": "1.33.2",
|
||||
"version": "1.34.0",
|
||||
"description": "Your Second Brain",
|
||||
"author": "Khoj Inc. <team@khoj.dev>",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
;; Saba Imran <saba@khoj.dev>
|
||||
;; Description: Your Second Brain
|
||||
;; Keywords: search, chat, ai, org-mode, outlines, markdown, pdf, image
|
||||
;; Version: 1.33.2
|
||||
;; Version: 1.34.0
|
||||
;; 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "khoj",
|
||||
"name": "Khoj",
|
||||
"version": "1.33.2",
|
||||
"version": "1.34.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Your Second Brain",
|
||||
"author": "Khoj Inc.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Khoj",
|
||||
"version": "1.33.2",
|
||||
"version": "1.34.0",
|
||||
"description": "Your Second Brain",
|
||||
"author": "Debanjum Singh Solanky, Saba Imran <team@khoj.dev>",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -501,6 +501,7 @@ export class KhojChatView extends KhojPaneView {
|
||||
conversationId?: string,
|
||||
images?: string[],
|
||||
excalidrawDiagram?: string,
|
||||
mermaidjsDiagram?: string
|
||||
) {
|
||||
if (!message) return;
|
||||
|
||||
@@ -509,8 +510,9 @@ export class KhojChatView extends KhojPaneView {
|
||||
intentType?.includes("text-to-image") ||
|
||||
intentType === "excalidraw" ||
|
||||
(images && images.length > 0) ||
|
||||
mermaidjsDiagram ||
|
||||
excalidrawDiagram) {
|
||||
let imageMarkdown = this.generateImageMarkdown(message, intentType ?? "", inferredQueries, conversationId, images, excalidrawDiagram);
|
||||
let imageMarkdown = this.generateImageMarkdown(message, intentType ?? "", inferredQueries, conversationId, images, excalidrawDiagram, mermaidjsDiagram);
|
||||
chatMessageEl = this.renderMessage({
|
||||
chatBodyEl: chatEl,
|
||||
message: imageMarkdown,
|
||||
@@ -542,28 +544,23 @@ export class KhojChatView extends KhojPaneView {
|
||||
chatMessageBodyEl.appendChild(this.createReferenceSection(references));
|
||||
}
|
||||
|
||||
generateImageMarkdown(message: string, intentType: string, inferredQueries?: string[], conversationId?: string, images?: string[], excalidrawDiagram?: string): string {
|
||||
generateImageMarkdown(message: string, intentType: string, inferredQueries?: string[], conversationId?: string, images?: string[], excalidrawDiagram?: string, mermaidjsDiagram?: string): string {
|
||||
let imageMarkdown = "";
|
||||
if (intentType === "text-to-image") {
|
||||
imageMarkdown = ``;
|
||||
} else if (intentType === "text-to-image2") {
|
||||
imageMarkdown = ``;
|
||||
} else if (intentType === "text-to-image-v3") {
|
||||
imageMarkdown = ``;
|
||||
imageMarkdown = ``;
|
||||
} else if (intentType === "excalidraw" || excalidrawDiagram) {
|
||||
const domain = this.setting.khojUrl.endsWith("/") ? this.setting.khojUrl : `${this.setting.khojUrl}/`;
|
||||
const redirectMessage = `Hey, I'm not ready to show you diagrams yet here. But you can view it in ${domain}chat?conversationId=${conversationId}`;
|
||||
imageMarkdown = redirectMessage;
|
||||
} else if (mermaidjsDiagram) {
|
||||
imageMarkdown = "```mermaid\n" + mermaidjsDiagram + "\n```";
|
||||
} else if (images && images.length > 0) {
|
||||
for (let image of images) {
|
||||
if (image.startsWith("https://")) {
|
||||
imageMarkdown += `\n\n`;
|
||||
} else {
|
||||
imageMarkdown += `\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
imageMarkdown += `${message}`;
|
||||
imageMarkdown += images.map(image => ``).join('\n\n');
|
||||
imageMarkdown += message;
|
||||
}
|
||||
|
||||
if (images?.length === 0 && inferredQueries) {
|
||||
@@ -961,6 +958,7 @@ export class KhojChatView extends KhojPaneView {
|
||||
chatBodyEl.dataset.conversationId ?? "",
|
||||
chatLog.images,
|
||||
chatLog.excalidrawDiagram,
|
||||
chatLog.mermaidjsDiagram,
|
||||
);
|
||||
// push the user messages to the chat history
|
||||
if (chatLog.by === "you") {
|
||||
@@ -1077,7 +1075,7 @@ export class KhojChatView extends KhojPaneView {
|
||||
}
|
||||
|
||||
handleJsonResponse(jsonData: any): void {
|
||||
if (jsonData.image || jsonData.detail || jsonData.images || jsonData.excalidrawDiagram) {
|
||||
if (jsonData.image || jsonData.detail || jsonData.images || jsonData.mermaidjsDiagram) {
|
||||
this.chatMessageState.rawResponse = this.handleImageResponse(jsonData, this.chatMessageState.rawResponse);
|
||||
} else if (jsonData.response) {
|
||||
this.chatMessageState.rawResponse = jsonData.response;
|
||||
@@ -1450,7 +1448,7 @@ export class KhojChatView extends KhojPaneView {
|
||||
} else if (imageJson.intentType === "text-to-image2") {
|
||||
rawResponse += ``;
|
||||
} else if (imageJson.intentType === "text-to-image-v3") {
|
||||
rawResponse = ``;
|
||||
rawResponse = ``;
|
||||
} else if (imageJson.intentType === "excalidraw") {
|
||||
const domain = this.setting.khojUrl.endsWith("/") ? this.setting.khojUrl : `${this.setting.khojUrl}/`;
|
||||
const redirectMessage = `Hey, I'm not ready to show you diagrams yet here. But you can view it in ${domain}`;
|
||||
@@ -1462,17 +1460,14 @@ export class KhojChatView extends KhojPaneView {
|
||||
} else if (imageJson.images) {
|
||||
// If response has images field, response is a list of generated images.
|
||||
imageJson.images.forEach((image: any) => {
|
||||
|
||||
if (image.startsWith("http")) {
|
||||
rawResponse += `\n\n`;
|
||||
} else {
|
||||
rawResponse += `\n\n`;
|
||||
}
|
||||
rawResponse += `\n\n`;
|
||||
});
|
||||
} else if (imageJson.excalidrawDiagram) {
|
||||
const domain = this.setting.khojUrl.endsWith("/") ? this.setting.khojUrl : `${this.setting.khojUrl}/`;
|
||||
const redirectMessage = `Hey, I'm not ready to show you diagrams yet here. But you can view it in ${domain}`;
|
||||
rawResponse += redirectMessage;
|
||||
} else if (imageJson.mermaidjsDiagram) {
|
||||
rawResponse += imageJson.mermaidjsDiagram;
|
||||
}
|
||||
|
||||
// If response has detail field, response is an error message.
|
||||
|
||||
@@ -108,5 +108,6 @@
|
||||
"1.32.2": "0.15.0",
|
||||
"1.33.0": "0.15.0",
|
||||
"1.33.1": "0.15.0",
|
||||
"1.33.2": "0.15.0"
|
||||
"1.33.2": "0.15.0",
|
||||
"1.34.0": "0.15.0"
|
||||
}
|
||||
|
||||
@@ -354,7 +354,15 @@ export default function Chat() {
|
||||
try {
|
||||
await readChatStream(response);
|
||||
} catch (err) {
|
||||
const apiError = await response.json();
|
||||
let apiError;
|
||||
try {
|
||||
apiError = await response.json();
|
||||
} catch (err) {
|
||||
// Error reading API error response
|
||||
apiError = {
|
||||
streamError: "Error reading API error response stream. Expected JSON response.",
|
||||
};
|
||||
}
|
||||
console.error(apiError);
|
||||
// Retrieve latest message being processed
|
||||
const currentMessage = messages.find((message) => !message.completed);
|
||||
@@ -365,7 +373,9 @@ export default function Chat() {
|
||||
const errorName = (err as Error).name;
|
||||
if (errorMessage.includes("Error in input stream"))
|
||||
currentMessage.rawResponse = `Woops! The connection broke while I was writing my thoughts down. Maybe try again in a bit or dislike this message if the issue persists?`;
|
||||
else if (response.status === 429) {
|
||||
else if (apiError.streamError) {
|
||||
currentMessage.rawResponse = `Umm, not sure what just happened but I lost my train of thought. Could you try again or ask my developers to look into this if the issue persists? They can be contacted at the Khoj Github, Discord or team@khoj.dev.`;
|
||||
} else if (response.status === 429) {
|
||||
"detail" in apiError
|
||||
? (currentMessage.rawResponse = `${apiError.detail}`)
|
||||
: (currentMessage.rawResponse = `I'm a bit overwhelmed at the moment. Could you try again in a bit or dislike this message if the issue persists?`);
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface MessageMetadata {
|
||||
|
||||
export interface GeneratedAssetsData {
|
||||
images: string[];
|
||||
excalidrawDiagram: string;
|
||||
mermaidjsDiagram: string;
|
||||
files: AttachedFileText[];
|
||||
}
|
||||
|
||||
@@ -114,8 +114,8 @@ export function processMessageChunk(
|
||||
currentMessage.generatedImages = generatedAssets.images;
|
||||
}
|
||||
|
||||
if (generatedAssets.excalidrawDiagram) {
|
||||
currentMessage.generatedExcalidrawDiagram = generatedAssets.excalidrawDiagram;
|
||||
if (generatedAssets.mermaidjsDiagram) {
|
||||
currentMessage.generatedMermaidjsDiagram = generatedAssets.mermaidjsDiagram;
|
||||
}
|
||||
|
||||
if (generatedAssets.files) {
|
||||
|
||||
@@ -418,7 +418,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
conversationId: props.conversationId,
|
||||
images: message.generatedImages,
|
||||
queryFiles: message.generatedFiles,
|
||||
excalidrawDiagram: message.generatedExcalidrawDiagram,
|
||||
mermaidjsDiagram: message.generatedMermaidjsDiagram,
|
||||
turnId: messageTurnId,
|
||||
}}
|
||||
conversationId={props.conversationId}
|
||||
|
||||
@@ -53,6 +53,7 @@ import { DialogTitle } from "@radix-ui/react-dialog";
|
||||
import { convertBytesToText } from "@/app/common/utils";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getIconFromFilename } from "@/app/common/iconUtils";
|
||||
import Mermaid from "../mermaid/mermaid";
|
||||
|
||||
const md = new markdownIt({
|
||||
html: true,
|
||||
@@ -164,6 +165,7 @@ export interface SingleChatMessage {
|
||||
turnId?: string;
|
||||
queryFiles?: AttachedFileText[];
|
||||
excalidrawDiagram?: string;
|
||||
mermaidjsDiagram?: string;
|
||||
}
|
||||
|
||||
export interface StreamMessage {
|
||||
@@ -182,9 +184,11 @@ export interface StreamMessage {
|
||||
turnId?: string;
|
||||
queryFiles?: AttachedFileText[];
|
||||
excalidrawDiagram?: string;
|
||||
mermaidjsDiagram?: string;
|
||||
generatedFiles?: AttachedFileText[];
|
||||
generatedImages?: string[];
|
||||
generatedExcalidrawDiagram?: string;
|
||||
generatedMermaidjsDiagram?: string;
|
||||
}
|
||||
|
||||
export interface ChatHistoryData {
|
||||
@@ -271,6 +275,7 @@ interface ChatMessageProps {
|
||||
turnId?: string;
|
||||
generatedImage?: string;
|
||||
excalidrawDiagram?: string;
|
||||
mermaidjsDiagram?: string;
|
||||
generatedFiles?: AttachedFileText[];
|
||||
}
|
||||
|
||||
@@ -358,6 +363,7 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [interrupted, setInterrupted] = useState<boolean>(false);
|
||||
const [excalidrawData, setExcalidrawData] = useState<string>("");
|
||||
const [mermaidjsData, setMermaidjsData] = useState<string>("");
|
||||
|
||||
const interruptedRef = useRef<boolean>(false);
|
||||
const messageRef = useRef<HTMLDivElement>(null);
|
||||
@@ -401,6 +407,10 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
|
||||
setExcalidrawData(props.chatMessage.excalidrawDiagram);
|
||||
}
|
||||
|
||||
if (props.chatMessage.mermaidjsDiagram) {
|
||||
setMermaidjsData(props.chatMessage.mermaidjsDiagram);
|
||||
}
|
||||
|
||||
// Replace LaTeX delimiters with placeholders
|
||||
message = message
|
||||
.replace(/\\\(/g, "LEFTPAREN")
|
||||
@@ -718,6 +728,7 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
|
||||
dangerouslySetInnerHTML={{ __html: markdownRendered }}
|
||||
/>
|
||||
{excalidrawData && <ExcalidrawComponent data={excalidrawData} />}
|
||||
{mermaidjsData && <Mermaid chart={mermaidjsData} />}
|
||||
</div>
|
||||
<div className={styles.teaserReferencesContainer}>
|
||||
<TeaserReferencesSection
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { CircleNotch } from "@phosphor-icons/react";
|
||||
import { AppSidebar } from "../appSidebar/appSidebar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useIsMobileWidth } from "@/app/common/utils";
|
||||
import { KhojLogoType } from "../logo/khojLogo";
|
||||
|
||||
interface LoadingProps {
|
||||
className?: string;
|
||||
@@ -7,21 +12,39 @@ interface LoadingProps {
|
||||
}
|
||||
|
||||
export default function Loading(props: LoadingProps) {
|
||||
const isMobileWidth = useIsMobileWidth();
|
||||
|
||||
return (
|
||||
// NOTE: We can display usage tips here for casual learning moments.
|
||||
<div
|
||||
className={
|
||||
props.className ||
|
||||
"bg-background opacity-50 flex items-center justify-center h-screen"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{props.message || "Loading"}{" "}
|
||||
<span>
|
||||
<CircleNotch className="inline animate-spin h-5 w-5" />
|
||||
</span>
|
||||
<SidebarProvider>
|
||||
<AppSidebar conversationId={""} />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
{isMobileWidth ? (
|
||||
<a className="p-0 no-underline" href="/">
|
||||
<KhojLogoType className="h-auto w-16" />
|
||||
</a>
|
||||
) : (
|
||||
<h2 className="text-lg">Ask Anything</h2>
|
||||
)}
|
||||
</header>
|
||||
</SidebarInset>
|
||||
<div
|
||||
className={
|
||||
props.className ||
|
||||
"bg-background opacity-50 flex items-center justify-center h-full w-full fixed top-0 left-0 z-50"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{props.message || "Loading"}{" "}
|
||||
<span>
|
||||
<CircleNotch className="inline animate-spin h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import styles from "./loginPrompt.module.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/ui/carousel";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
|
||||
export interface LoginPromptProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -181,6 +182,9 @@ export default function LoginPrompt(props: LoginPromptProps) {
|
||||
<DialogContent
|
||||
className={`flex flex-col gap-4 ${!useEmailSignIn ? "p-0 pb-4 m-0 max-w-xl" : "w-fit"}`}
|
||||
>
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle>Login Dialog</DialogTitle>
|
||||
</VisuallyHidden.Root>
|
||||
<div>
|
||||
{useEmailSignIn ? (
|
||||
<EmailSignInContext
|
||||
@@ -232,7 +236,7 @@ function EmailSignInContext({
|
||||
const [numFailures, setNumFailures] = useState(0);
|
||||
|
||||
function checkOTPAndRedirect() {
|
||||
const verifyUrl = `/auth/magic?code=${otp}&email=${email}`;
|
||||
const verifyUrl = `/auth/magic?code=${encodeURIComponent(otp)}&email=${encodeURIComponent(email)}`;
|
||||
|
||||
if (numFailures >= ALLOWED_OTP_ATTEMPTS) {
|
||||
setOTPError("Too many failed attempts. Please try again tomorrow.");
|
||||
|
||||
173
src/interface/web/app/components/mermaid/mermaid.tsx
Normal file
173
src/interface/web/app/components/mermaid/mermaid.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import mermaid from "mermaid";
|
||||
import { Download, Info } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MermaidProps {
|
||||
chart: string;
|
||||
}
|
||||
|
||||
const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||||
const [mermaidId] = useState(`mermaid-chart-${Math.random().toString(12).substring(7)}`);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
});
|
||||
|
||||
mermaid.parseError = (error) => {
|
||||
console.error("Mermaid errors:", error);
|
||||
// Extract error message from error object
|
||||
// Parse error message safely
|
||||
let errorMessage;
|
||||
try {
|
||||
errorMessage = typeof error === "string" ? JSON.parse(error) : error;
|
||||
} catch (e) {
|
||||
errorMessage = error?.toString() || "Unknown error";
|
||||
}
|
||||
|
||||
console.log("Mermaid error message:", errorMessage);
|
||||
|
||||
if (errorMessage.str !== "element is null") {
|
||||
setMermaidError(
|
||||
"Something went wrong while rendering the diagram. Please try again later or downvote the message if the issue persists.",
|
||||
);
|
||||
} else {
|
||||
setMermaidError(null);
|
||||
}
|
||||
};
|
||||
|
||||
mermaid.contentLoaded();
|
||||
}, []);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!elementRef.current) return;
|
||||
|
||||
try {
|
||||
// Get SVG element
|
||||
const svgElement = elementRef.current.querySelector("svg");
|
||||
if (!svgElement) throw new Error("No SVG found");
|
||||
|
||||
// Get SVG viewBox dimensions
|
||||
const viewBox = svgElement.getAttribute("viewBox")?.split(" ").map(Number) || [
|
||||
0, 0, 0, 0,
|
||||
];
|
||||
const [, , viewBoxWidth, viewBoxHeight] = viewBox;
|
||||
|
||||
// Create canvas with viewBox dimensions
|
||||
const canvas = document.createElement("canvas");
|
||||
const scale = 2; // For better resolution
|
||||
canvas.width = viewBoxWidth * scale;
|
||||
canvas.height = viewBoxHeight * scale;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to get canvas context");
|
||||
|
||||
// Convert SVG to data URL
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||
const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
|
||||
// Create and load image
|
||||
const img = new Image();
|
||||
img.src = svgUrl;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = () => {
|
||||
// Scale context for better resolution
|
||||
ctx.scale(scale, scale);
|
||||
ctx.drawImage(img, 0, 0, viewBoxWidth, viewBoxHeight);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to create blob"));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `mermaid-diagram-${Date.now()}.png`;
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
URL.revokeObjectURL(url);
|
||||
URL.revokeObjectURL(svgUrl);
|
||||
resolve(true);
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error("Failed to load SVG"));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error exporting diagram:", error);
|
||||
setMermaidError("Failed to export diagram");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (elementRef.current) {
|
||||
elementRef.current.removeAttribute("data-processed");
|
||||
|
||||
mermaid
|
||||
.run({
|
||||
nodes: [elementRef.current],
|
||||
})
|
||||
.then(() => {
|
||||
setMermaidError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
let errorMessage;
|
||||
try {
|
||||
errorMessage = typeof error === "string" ? JSON.parse(error) : error;
|
||||
} catch (e) {
|
||||
errorMessage = error?.toString() || "Unknown error";
|
||||
}
|
||||
|
||||
console.log("Mermaid error message:", errorMessage);
|
||||
|
||||
if (errorMessage.str !== "element is null") {
|
||||
setMermaidError(
|
||||
"Something went wrong while rendering the diagram. Please try again later or downvote the message if the issue persists.",
|
||||
);
|
||||
} else {
|
||||
setMermaidError(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{mermaidError ? (
|
||||
<div className="flex items-center gap-2 bg-red-100 border border-red-500 rounded-md p-3 mt-3 text-red-900 text-sm">
|
||||
<Info className="w-12 h-12" />
|
||||
<span>Error rendering diagram: {mermaidError}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id={mermaidId}
|
||||
ref={elementRef}
|
||||
className="mermaid"
|
||||
style={{
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
boxSizing: "border-box",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{chart}
|
||||
</div>
|
||||
)}
|
||||
{!mermaidError && (
|
||||
<Button onClick={handleExport} variant={"secondary"} className="mt-3">
|
||||
<Download className="w-5 h-5" />
|
||||
Export as PNG
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Mermaid;
|
||||
@@ -173,8 +173,10 @@ export default function Search() {
|
||||
const [searchResultsLoading, setSearchResultsLoading] = useState(false);
|
||||
const [focusSearchResult, setFocusSearchResult] = useState<SearchResult | null>(null);
|
||||
const [exampleQuery, setExampleQuery] = useState("");
|
||||
const [fileSuggestions, setFileSuggestions] = useState<string[]>([]);
|
||||
const [allFiles, setAllFiles] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isMobileWidth = useIsMobileWidth();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -183,8 +185,68 @@ export default function Search() {
|
||||
Math.floor(Math.random() * naturalLanguageSearchQueryExamples.length)
|
||||
],
|
||||
);
|
||||
|
||||
// Load all files once on page load
|
||||
fetch('/api/content/computer', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setAllFiles(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading files:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function getFileSuggestions(query: string) {
|
||||
const fileFilterMatch = query.match(/file:([^"\s]*|"[^"]*")?/);
|
||||
if (!fileFilterMatch) {
|
||||
setFileSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePrefix = fileFilterMatch[1]?.replace(/^"|"$/g, '').trim() || '';
|
||||
const filteredSuggestions = allFiles
|
||||
.filter(file => file.toLowerCase().includes(filePrefix.toLowerCase()))
|
||||
.sort()
|
||||
.slice(0, 10);
|
||||
|
||||
setFileSuggestions(filteredSuggestions);
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
|
||||
function handleSearchInputChange(value: string) {
|
||||
setSearchQuery(value);
|
||||
|
||||
// Clear previous search timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Get file suggestions immediately
|
||||
getFileSuggestions(value);
|
||||
|
||||
// Debounce search
|
||||
if (value.trim()) {
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
search();
|
||||
}, 750);
|
||||
}
|
||||
}
|
||||
|
||||
function applySuggestion(suggestion: string) {
|
||||
// Replace the file: filter with the selected suggestion
|
||||
const newQuery = searchQuery.replace(/file:([^"\s]*|"[^"]*")?/, `file:"${suggestion}"`);
|
||||
setSearchQuery(newQuery);
|
||||
setShowSuggestions(false);
|
||||
search();
|
||||
}
|
||||
|
||||
function search() {
|
||||
if (searchResultsLoading || !searchQuery.trim()) return;
|
||||
|
||||
@@ -205,30 +267,6 @@ export default function Search() {
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusSearchResult(null);
|
||||
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
search();
|
||||
}, 750); // 1000 milliseconds = 1 second
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar conversationId={""} />
|
||||
@@ -249,14 +287,38 @@ export default function Search() {
|
||||
<div className="md:w-3/4 sm:w-full mx-auto pt-6 md:pt-8">
|
||||
<div className="p-4 md:w-3/4 sm:w-full mx-auto">
|
||||
<div className="flex justify-between items-center border-2 border-muted p-1 gap-1 rounded-lg">
|
||||
<Input
|
||||
autoFocus={true}
|
||||
className="border-none pl-4"
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
type="search"
|
||||
placeholder="Search Documents"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
autoFocus={true}
|
||||
className="border-none pl-4"
|
||||
onChange={(e) => handleSearchInputChange(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (showSuggestions && fileSuggestions.length > 0) {
|
||||
applySuggestion(fileSuggestions[0]);
|
||||
} else {
|
||||
search();
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="search"
|
||||
placeholder="Search Documents (type 'file:' for file suggestions)"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{showSuggestions && fileSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-background border rounded-md shadow-lg">
|
||||
{fileSuggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-4 py-2 hover:bg-muted cursor-pointer"
|
||||
onClick={() => applySuggestion(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="px-2 gap-2 inline-flex items-center rounded border-l border-gray-300 hover:text-gray-500"
|
||||
onClick={() => search()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "khoj-ai",
|
||||
"version": "1.33.2",
|
||||
"version": "1.34.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -53,12 +53,13 @@
|
||||
"eslint-config-next": "14.2.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"intl-tel-input": "^23.8.1",
|
||||
"katex": "^0.16.10",
|
||||
"katex": "^0.16.21",
|
||||
"libphonenumber-js": "^1.11.4",
|
||||
"lucide-react": "^0.468.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-highlightjs": "^4.1.0",
|
||||
"next": "14.2.15",
|
||||
"mermaid": "^11.4.1",
|
||||
"next": "14.2.21",
|
||||
"nodemon": "^3.1.3",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -234,7 +234,7 @@ def configure_server(
|
||||
|
||||
if ConversationAdapters.has_valid_ai_model_api():
|
||||
ai_model_api = ConversationAdapters.get_ai_model_api()
|
||||
state.openai_client = openai.OpenAI(api_key=ai_model_api.api_key)
|
||||
state.openai_client = openai.OpenAI(api_key=ai_model_api.api_key, base_url=ai_model_api.api_base_url)
|
||||
|
||||
# Initialize Search Models from Config and initialize content
|
||||
try:
|
||||
@@ -249,6 +249,7 @@ def configure_server(
|
||||
model.bi_encoder,
|
||||
model.embeddings_inference_endpoint,
|
||||
model.embeddings_inference_endpoint_api_key,
|
||||
model.embeddings_inference_endpoint_type,
|
||||
query_encode_kwargs=model.bi_encoder_query_encode_config,
|
||||
docs_encode_kwargs=model.bi_encoder_docs_encode_config,
|
||||
model_kwargs=model.bi_encoder_model_config,
|
||||
|
||||
@@ -1288,7 +1288,7 @@ class ConversationAdapters:
|
||||
|
||||
@staticmethod
|
||||
async def get_speech_to_text_config():
|
||||
return await SpeechToTextModelOptions.objects.filter().afirst()
|
||||
return await SpeechToTextModelOptions.objects.filter().prefetch_related("ai_model_api").afirst()
|
||||
|
||||
@staticmethod
|
||||
@arequire_valid_user
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote
|
||||
|
||||
from apscheduler.job import Job
|
||||
from django.contrib import admin, messages
|
||||
@@ -154,8 +155,9 @@ class KhojUserAdmin(UserAdmin, unfold_admin.ModelAdmin):
|
||||
for user in queryset:
|
||||
if user.email:
|
||||
host = request.get_host()
|
||||
unique_id = user.email_verification_code
|
||||
login_url = f"{host}/auth/magic?code={unique_id}&email={user.email}"
|
||||
otp = quote(user.email_verification_code)
|
||||
encoded_email = quote(user.email)
|
||||
login_url = f"{host}/auth/magic?code={otp}&email={encoded_email}"
|
||||
messages.info(request, f"Email login URL for {user.email}: {login_url}")
|
||||
|
||||
get_email_login_url.short_description = "Get email login URL" # type: ignore
|
||||
|
||||
@@ -3,11 +3,11 @@ from typing import List
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Q
|
||||
from tqdm import tqdm
|
||||
|
||||
from khoj.database.adapters import get_default_search_model
|
||||
from khoj.database.models import Agent, Entry, KhojUser, SearchModelConfig
|
||||
from khoj.database.models import Entry, SearchModelConfig
|
||||
from khoj.processor.embeddings import EmbeddingsModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -74,6 +74,7 @@ class Command(BaseCommand):
|
||||
model.bi_encoder,
|
||||
model.embeddings_inference_endpoint,
|
||||
model.embeddings_inference_endpoint_api_key,
|
||||
model.embeddings_inference_endpoint_type,
|
||||
query_encode_kwargs=model.bi_encoder_query_encode_config,
|
||||
docs_encode_kwargs=model.bi_encoder_docs_encode_config,
|
||||
model_kwargs=model.bi_encoder_model_config,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-08 15:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_endpoint_type(apps, schema_editor):
|
||||
SearchModelConfig = apps.get_model("database", "SearchModelConfig")
|
||||
SearchModelConfig.objects.filter(embeddings_inference_endpoint__isnull=False).exclude(
|
||||
embeddings_inference_endpoint=""
|
||||
).update(embeddings_inference_endpoint_type="huggingface")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("database", "0078_khojuser_email_verification_code_expiry"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="searchmodelconfig",
|
||||
name="embeddings_inference_endpoint_type",
|
||||
field=models.CharField(
|
||||
choices=[("huggingface", "Huggingface"), ("openai", "Openai"), ("local", "Local")],
|
||||
default="local",
|
||||
max_length=200,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_endpoint_type, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-15 11:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("database", "0079_searchmodelconfig_embeddings_inference_endpoint_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="speechtotextmodeloptions",
|
||||
name="ai_model_api",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="database.aimodelapi",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -109,6 +109,7 @@ class ChatMessage(PydanticBaseModel):
|
||||
images: Optional[List[str]] = None
|
||||
queryFiles: Optional[List[Dict]] = None
|
||||
excalidrawDiagram: Optional[List[Dict]] = None
|
||||
mermaidjsDiagram: str = None
|
||||
by: str
|
||||
turnId: Optional[str] = None
|
||||
intent: Optional[Intent] = None
|
||||
@@ -481,6 +482,11 @@ class SearchModelConfig(DbBaseModel):
|
||||
class ModelType(models.TextChoices):
|
||||
TEXT = "text"
|
||||
|
||||
class ApiType(models.TextChoices):
|
||||
HUGGINGFACE = "huggingface"
|
||||
OPENAI = "openai"
|
||||
LOCAL = "local"
|
||||
|
||||
# This is the model name exposed to users on their settings page
|
||||
name = models.CharField(max_length=200, default="default")
|
||||
# Type of content the model can generate embeddings for
|
||||
@@ -501,6 +507,10 @@ class SearchModelConfig(DbBaseModel):
|
||||
embeddings_inference_endpoint = models.CharField(max_length=200, default=None, null=True, blank=True)
|
||||
# Inference server API Key to use for embeddings inference. Bi-encoder model should be hosted on this server
|
||||
embeddings_inference_endpoint_api_key = models.CharField(max_length=200, default=None, null=True, blank=True)
|
||||
# Inference server API type to use for embeddings inference.
|
||||
embeddings_inference_endpoint_type = models.CharField(
|
||||
max_length=200, choices=ApiType.choices, default=ApiType.LOCAL
|
||||
)
|
||||
# Inference server API endpoint to use for embeddings inference. Cross-encoder model should be hosted on this server
|
||||
cross_encoder_inference_endpoint = models.CharField(max_length=200, default=None, null=True, blank=True)
|
||||
# Inference server API Key to use for embeddings inference. Cross-encoder model should be hosted on this server
|
||||
@@ -557,6 +567,7 @@ class SpeechToTextModelOptions(DbBaseModel):
|
||||
|
||||
model_name = models.CharField(max_length=200, default="base")
|
||||
model_type = models.CharField(max_length=200, choices=ModelType.choices, default=ModelType.OFFLINE)
|
||||
ai_model_api = models.ForeignKey(AiModelApi, on_delete=models.CASCADE, default=None, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.model_name} - {self.model_type}"
|
||||
|
||||
@@ -194,7 +194,7 @@ Limit your response to 3 sentences max. Be succinct, clear, and informative.
|
||||
## Diagram Generation
|
||||
## --
|
||||
|
||||
improve_diagram_description_prompt = PromptTemplate.from_template(
|
||||
improve_excalidraw_diagram_description_prompt = PromptTemplate.from_template(
|
||||
"""
|
||||
You are an architect working with a novice digital artist using a diagramming software.
|
||||
{personality_context}
|
||||
@@ -338,6 +338,123 @@ Diagram Description: {query}
|
||||
""".strip()
|
||||
)
|
||||
|
||||
improve_mermaid_js_diagram_description_prompt = PromptTemplate.from_template(
|
||||
"""
|
||||
You are a senior architect working with an illustrator using a diagramming software.
|
||||
{personality_context}
|
||||
|
||||
Given a particular request, you need to translate it to to a detailed description that the illustrator can use to create a diagram.
|
||||
|
||||
You can use the following diagram types in your instructions:
|
||||
- Flowchart
|
||||
- Sequence Diagram
|
||||
- Gantt Chart (only for time-based queries after 0 AD)
|
||||
- State Diagram
|
||||
- Pie Chart
|
||||
|
||||
Use these primitives to describe what sort of diagram the drawer should create in natural language, not special syntax. We must recreate the diagram every time, so include all relevant prior information in your description.
|
||||
|
||||
- Describe the layout, components, and connections.
|
||||
- Use simple, concise language.
|
||||
|
||||
Today's Date: {current_date}
|
||||
User's Location: {location}
|
||||
|
||||
User's Notes:
|
||||
{references}
|
||||
|
||||
Online References:
|
||||
{online_results}
|
||||
|
||||
Conversation Log:
|
||||
{chat_history}
|
||||
|
||||
Query: {query}
|
||||
|
||||
Enhanced Description:
|
||||
""".strip()
|
||||
)
|
||||
|
||||
mermaid_js_diagram_generation_prompt = PromptTemplate.from_template(
|
||||
"""
|
||||
You are a designer with the ability to describe diagrams to compose in professional, fine detail. You dive into the details and make labels, connections, and shapes to represent complex systems.
|
||||
{personality_context}
|
||||
|
||||
----Goals----
|
||||
You need to create a declarative description of the diagram and relevant components, using the Mermaid.js syntax.
|
||||
|
||||
You can choose from the following diagram types:
|
||||
- Flowchart
|
||||
- Sequence Diagram
|
||||
- State Diagram
|
||||
- Gantt Chart
|
||||
- Pie Chart
|
||||
|
||||
----Examples----
|
||||
---
|
||||
title: Node
|
||||
---
|
||||
|
||||
flowchart LR
|
||||
id["This is the start"] --> id2["This is the end"]
|
||||
|
||||
sequenceDiagram
|
||||
Alice->>John: Hello John, how are you?
|
||||
John-->>Alice: Great!
|
||||
Alice-)John: See you later!
|
||||
|
||||
stateDiagram-v2
|
||||
[*] --> Still
|
||||
Still --> [*]
|
||||
|
||||
Still --> Moving
|
||||
Moving --> Still
|
||||
Moving --> Crash
|
||||
Crash --> [*]
|
||||
|
||||
gantt
|
||||
title A Gantt Diagram
|
||||
dateFormat YYYY-MM-DD
|
||||
section Section
|
||||
A task :a1, 2014-01-01, 30d
|
||||
Another task :after a1, 20d
|
||||
section Another
|
||||
Task in Another :2014-01-12, 12d
|
||||
another task :24d
|
||||
|
||||
pie title Pets adopted by volunteers
|
||||
"Dogs" : 10
|
||||
"Cats" : 30
|
||||
"Rats" : 60
|
||||
|
||||
flowchart TB
|
||||
subgraph "Group 1"
|
||||
a1["Start Node"] --> a2["End Node"]
|
||||
end
|
||||
subgraph "Group 2"
|
||||
b1["Process 1"] --> b2["Process 2"]
|
||||
end
|
||||
subgraph "Group 3"
|
||||
c1["Input"] --> c2["Output"]
|
||||
end
|
||||
a["Group 1"] --> b["Group 2"]
|
||||
c["Group 3"] --> d["Group 2"]
|
||||
|
||||
----Process----
|
||||
Create your diagram with great composition and intuitiveness from the provided context and user prompt below.
|
||||
- You may use subgraphs to group elements together. Each subgraph must have a title.
|
||||
- **You must wrap ALL entity and node labels in double quotes**, example: "My Node Label"
|
||||
- **All nodes MUST use the id["label"] format**. For example: node1["My Node Label"]
|
||||
- Custom style are not permitted. Default styles only.
|
||||
- JUST provide the diagram, no additional text or context. Say nothing else in your response except the diagram.
|
||||
- Keep diagrams simple - maximum 15 nodes
|
||||
- Every node inside a subgraph MUST use square bracket notation: id["label"]
|
||||
|
||||
output: {query}
|
||||
|
||||
""".strip()
|
||||
)
|
||||
|
||||
failed_diagram_generation = PromptTemplate.from_template(
|
||||
"""
|
||||
You attempted to programmatically generate a diagram but failed due to a system issue. You are normally able to generate diagrams, but you encountered a system issue this time.
|
||||
|
||||
@@ -62,7 +62,7 @@ model_to_prompt_size = {
|
||||
"claude-3-5-sonnet-20241022": 60000,
|
||||
"claude-3-5-haiku-20241022": 60000,
|
||||
# Offline Models
|
||||
"Qwen/Qwen2.5-14B-Instruct-GGUF": 20000,
|
||||
"bartowski/Qwen2.5-14B-Instruct-GGUF": 20000,
|
||||
"bartowski/Meta-Llama-3.1-8B-Instruct-GGUF": 20000,
|
||||
"bartowski/Llama-3.2-3B-Instruct-GGUF": 20000,
|
||||
"bartowski/gemma-2-9b-it-GGUF": 6000,
|
||||
@@ -266,7 +266,7 @@ def save_to_conversation_log(
|
||||
raw_query_files: List[FileAttachment] = [],
|
||||
generated_images: List[str] = [],
|
||||
raw_generated_files: List[FileAttachment] = [],
|
||||
generated_excalidraw_diagram: str = None,
|
||||
generated_mermaidjs_diagram: str = None,
|
||||
train_of_thought: List[Any] = [],
|
||||
tracer: Dict[str, Any] = {},
|
||||
):
|
||||
@@ -290,8 +290,8 @@ def save_to_conversation_log(
|
||||
"queryFiles": [file.model_dump(mode="json") for file in raw_generated_files],
|
||||
}
|
||||
|
||||
if generated_excalidraw_diagram:
|
||||
khoj_message_metadata["excalidrawDiagram"] = generated_excalidraw_diagram
|
||||
if generated_mermaidjs_diagram:
|
||||
khoj_message_metadata["mermaidjsDiagram"] = generated_mermaidjs_diagram
|
||||
|
||||
updated_conversation = message_to_log(
|
||||
user_message=q,
|
||||
@@ -441,7 +441,7 @@ def generate_chatml_messages_with_context(
|
||||
"query": chat.get("intent", {}).get("inferred-queries", [user_message])[0],
|
||||
}
|
||||
|
||||
if not is_none_or_empty(chat.get("excalidrawDiagram")) and role == "assistant":
|
||||
if not is_none_or_empty(chat.get("mermaidjsDiagram")) and role == "assistant":
|
||||
generated_assets["diagram"] = {
|
||||
"query": chat.get("intent", {}).get("inferred-queries", [user_message])[0],
|
||||
}
|
||||
@@ -593,6 +593,11 @@ def clean_json(response: str):
|
||||
return response.strip().replace("\n", "").removeprefix("```json").removesuffix("```")
|
||||
|
||||
|
||||
def clean_mermaidjs(response: str):
|
||||
"""Remove any markdown mermaidjs codeblock and newline formatting if present. Useful for non schema enforceable models"""
|
||||
return response.strip().removeprefix("```mermaid").removesuffix("```")
|
||||
|
||||
|
||||
def clean_code_python(code: str):
|
||||
"""Remove any markdown codeblock and newline formatting if present. Useful for non schema enforceable models"""
|
||||
return code.strip().removeprefix("```python").removesuffix("```")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from typing import List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import openai
|
||||
import requests
|
||||
import tqdm
|
||||
from sentence_transformers import CrossEncoder, SentenceTransformer
|
||||
@@ -13,7 +15,14 @@ from tenacity import (
|
||||
)
|
||||
from torch import nn
|
||||
|
||||
from khoj.utils.helpers import fix_json_dict, get_device, merge_dicts, timer
|
||||
from khoj.database.models import SearchModelConfig
|
||||
from khoj.utils.helpers import (
|
||||
fix_json_dict,
|
||||
get_device,
|
||||
get_openai_client,
|
||||
merge_dicts,
|
||||
timer,
|
||||
)
|
||||
from khoj.utils.rawconfig import SearchResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,6 +34,7 @@ class EmbeddingsModel:
|
||||
model_name: str = "thenlper/gte-small",
|
||||
embeddings_inference_endpoint: str = None,
|
||||
embeddings_inference_endpoint_api_key: str = None,
|
||||
embeddings_inference_endpoint_type=SearchModelConfig.ApiType.LOCAL,
|
||||
query_encode_kwargs: dict = {},
|
||||
docs_encode_kwargs: dict = {},
|
||||
model_kwargs: dict = {},
|
||||
@@ -37,15 +47,16 @@ class EmbeddingsModel:
|
||||
self.model_name = model_name
|
||||
self.inference_endpoint = embeddings_inference_endpoint
|
||||
self.api_key = embeddings_inference_endpoint_api_key
|
||||
with timer(f"Loaded embedding model {self.model_name}", logger):
|
||||
self.embeddings_model = SentenceTransformer(self.model_name, **self.model_kwargs)
|
||||
|
||||
def inference_server_enabled(self) -> bool:
|
||||
return self.api_key is not None and self.inference_endpoint is not None
|
||||
self.inference_endpoint_type = embeddings_inference_endpoint_type
|
||||
if self.inference_endpoint_type == SearchModelConfig.ApiType.LOCAL:
|
||||
with timer(f"Loaded embedding model {self.model_name}", logger):
|
||||
self.embeddings_model = SentenceTransformer(self.model_name, **self.model_kwargs)
|
||||
|
||||
def embed_query(self, query):
|
||||
if self.inference_server_enabled():
|
||||
return self.embed_with_api([query])[0]
|
||||
if self.inference_endpoint_type == SearchModelConfig.ApiType.HUGGINGFACE:
|
||||
return self.embed_with_hf([query])[0]
|
||||
elif self.inference_endpoint_type == SearchModelConfig.ApiType.OPENAI:
|
||||
return self.embed_with_openai([query])[0]
|
||||
return self.embeddings_model.encode([query], **self.query_encode_kwargs)[0]
|
||||
|
||||
@retry(
|
||||
@@ -54,7 +65,7 @@ class EmbeddingsModel:
|
||||
stop=stop_after_attempt(5),
|
||||
before_sleep=before_sleep_log(logger, logging.DEBUG),
|
||||
)
|
||||
def embed_with_api(self, docs):
|
||||
def embed_with_hf(self, docs):
|
||||
payload = {"inputs": docs}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -71,23 +82,38 @@ class EmbeddingsModel:
|
||||
raise e
|
||||
return response.json()["embeddings"]
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception_type(requests.exceptions.HTTPError),
|
||||
wait=wait_random_exponential(multiplier=1, max=10),
|
||||
stop=stop_after_attempt(5),
|
||||
before_sleep=before_sleep_log(logger, logging.DEBUG),
|
||||
)
|
||||
def embed_with_openai(self, docs):
|
||||
client = get_openai_client(self.api_key, self.inference_endpoint)
|
||||
response = client.embeddings.create(input=docs, model=self.model_name, encoding_format="float")
|
||||
return [item.embedding for item in response.data]
|
||||
|
||||
def embed_documents(self, docs):
|
||||
if self.inference_server_enabled():
|
||||
if "huggingface" not in self.inference_endpoint:
|
||||
logger.warning(
|
||||
f"Unsupported inference endpoint: {self.inference_endpoint}. Only HuggingFace supported. Generating embeddings on device instead."
|
||||
)
|
||||
return self.embeddings_model.encode(docs, **self.docs_encode_kwargs).tolist()
|
||||
# break up the docs payload in chunks of 1000 to avoid hitting rate limits
|
||||
embeddings = []
|
||||
with tqdm.tqdm(total=len(docs)) as pbar:
|
||||
for i in range(0, len(docs), 1000):
|
||||
docs_to_embed = docs[i : i + 1000]
|
||||
generated_embeddings = self.embed_with_api(docs_to_embed)
|
||||
embeddings += generated_embeddings
|
||||
pbar.update(1000)
|
||||
return embeddings
|
||||
return self.embeddings_model.encode(docs, **self.docs_encode_kwargs).tolist() if docs else []
|
||||
if self.inference_endpoint_type == SearchModelConfig.ApiType.LOCAL:
|
||||
return self.embeddings_model.encode(docs, **self.docs_encode_kwargs).tolist() if docs else []
|
||||
elif self.inference_endpoint_type == SearchModelConfig.ApiType.HUGGINGFACE:
|
||||
embed_with_api = self.embed_with_hf
|
||||
elif self.inference_endpoint_type == SearchModelConfig.ApiType.OPENAI:
|
||||
embed_with_api = self.embed_with_openai
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unsupported inference endpoint: {self.inference_endpoint_type}. Generating embeddings locally instead."
|
||||
)
|
||||
return self.embeddings_model.encode(docs, **self.docs_encode_kwargs).tolist()
|
||||
# break up the docs payload in chunks of 1000 to avoid hitting rate limits
|
||||
embeddings = []
|
||||
with tqdm.tqdm(total=len(docs)) as pbar:
|
||||
for i in range(0, len(docs), 1000):
|
||||
docs_to_embed = docs[i : i + 1000]
|
||||
generated_embeddings = embed_with_api(docs_to_embed)
|
||||
embeddings += generated_embeddings
|
||||
pbar.update(1000)
|
||||
return embeddings
|
||||
|
||||
|
||||
class CrossEncoderModel:
|
||||
|
||||
@@ -111,7 +111,7 @@ async def text_to_image(
|
||||
image_url = upload_image(webp_image_bytes, user.uuid)
|
||||
|
||||
if not image_url:
|
||||
image = base64.b64encode(webp_image_bytes).decode("utf-8")
|
||||
image = f"data:image/webp;base64,{base64.b64encode(webp_image_bytes).decode('utf-8')}"
|
||||
|
||||
yield image_url or image, status_code, image_prompt
|
||||
|
||||
@@ -119,25 +119,27 @@ async def text_to_image(
|
||||
def generate_image_with_openai(
|
||||
improved_image_prompt: str, text_to_image_config: TextToImageModelConfig, text2image_model: str
|
||||
):
|
||||
"Generate image using OpenAI API"
|
||||
"Generate image using OpenAI (compatible) API"
|
||||
|
||||
# Get the API key from the user's configuration
|
||||
# Get the API config from the user's configuration
|
||||
api_key = None
|
||||
if text_to_image_config.api_key:
|
||||
api_key = text_to_image_config.api_key
|
||||
openai_client = openai.OpenAI(api_key=api_key)
|
||||
elif text_to_image_config.ai_model_api:
|
||||
api_key = text_to_image_config.ai_model_api.api_key
|
||||
api_base_url = text_to_image_config.ai_model_api.api_base_url
|
||||
openai_client = openai.OpenAI(api_key=api_key, base_url=api_base_url)
|
||||
elif state.openai_client:
|
||||
api_key = state.openai_client.api_key
|
||||
auth_header = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
openai_client = state.openai_client
|
||||
|
||||
# Generate image using OpenAI API
|
||||
OPENAI_IMAGE_GEN_STYLE = "vivid"
|
||||
response = state.openai_client.images.generate(
|
||||
response = openai_client.images.generate(
|
||||
prompt=improved_image_prompt,
|
||||
model=text2image_model,
|
||||
style=OPENAI_IMAGE_GEN_STYLE,
|
||||
response_format="b64_json",
|
||||
extra_headers=auth_header,
|
||||
)
|
||||
|
||||
# Extract the base64 image from the response
|
||||
|
||||
@@ -30,6 +30,8 @@ from khoj.utils.rawconfig import LocationData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GOOGLE_SEARCH_API_KEY = os.getenv("GOOGLE_SEARCH_API_KEY")
|
||||
GOOGLE_SEARCH_ENGINE_ID = os.getenv("GOOGLE_SEARCH_ENGINE_ID")
|
||||
SERPER_DEV_API_KEY = os.getenv("SERPER_DEV_API_KEY")
|
||||
SERPER_DEV_URL = "https://google.serper.dev/search"
|
||||
|
||||
@@ -96,19 +98,25 @@ async def search_online(
|
||||
yield response_dict
|
||||
return
|
||||
|
||||
logger.info(f"🌐 Searching the Internet for {subqueries}")
|
||||
if GOOGLE_SEARCH_API_KEY and GOOGLE_SEARCH_ENGINE_ID:
|
||||
search_engine = "Google"
|
||||
search_func = search_with_google
|
||||
elif SERPER_DEV_API_KEY:
|
||||
search_engine = "Serper"
|
||||
search_func = search_with_serper
|
||||
elif JINA_API_KEY:
|
||||
search_engine = "Jina"
|
||||
search_func = search_with_jina
|
||||
else:
|
||||
search_engine = "Searxng"
|
||||
search_func = search_with_searxng
|
||||
|
||||
logger.info(f"🌐 Searching the Internet with {search_engine} for {subqueries}")
|
||||
if send_status_func:
|
||||
subqueries_str = "\n- " + "\n- ".join(subqueries)
|
||||
async for event in send_status_func(f"**Searching the Internet for**: {subqueries_str}"):
|
||||
yield {ChatEvent.STATUS: event}
|
||||
|
||||
if SERPER_DEV_API_KEY:
|
||||
search_func = search_with_serper
|
||||
elif JINA_API_KEY:
|
||||
search_func = search_with_jina
|
||||
else:
|
||||
search_func = search_with_searxng
|
||||
|
||||
with timer(f"Internet searches for {subqueries} took", logger):
|
||||
search_tasks = [search_func(subquery, location) for subquery in subqueries]
|
||||
search_results = await asyncio.gather(*search_tasks)
|
||||
@@ -195,6 +203,56 @@ async def search_with_searxng(query: str, location: LocationData) -> Tuple[str,
|
||||
return query, {}
|
||||
|
||||
|
||||
async def search_with_google(query: str, location: LocationData) -> Tuple[str, Dict[str, List[Dict]]]:
|
||||
country_code = location.country_code.lower() if location and location.country_code else "us"
|
||||
base_url = "https://www.googleapis.com/customsearch/v1"
|
||||
params = {
|
||||
"key": GOOGLE_SEARCH_API_KEY,
|
||||
"cx": GOOGLE_SEARCH_ENGINE_ID,
|
||||
"q": query,
|
||||
"cr": f"country{country_code.upper()}", # Country restrict parameter
|
||||
"gl": country_code, # Geolocation parameter
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(base_url, params=params) as response:
|
||||
if response.status != 200:
|
||||
logger.error(await response.text())
|
||||
return query, {}
|
||||
|
||||
json_response = await response.json()
|
||||
|
||||
# Transform Google's response format to match Serper's format
|
||||
organic_results = []
|
||||
if "items" in json_response:
|
||||
organic_results = [
|
||||
{
|
||||
"title": item.get("title", ""),
|
||||
"link": item.get("link", ""),
|
||||
"snippet": item.get("snippet", ""),
|
||||
"content": None, # Google Search API doesn't provide full content
|
||||
}
|
||||
for item in json_response["items"]
|
||||
]
|
||||
|
||||
# Format knowledge graph if available
|
||||
knowledge_graph = {}
|
||||
if "knowledge_graph" in json_response:
|
||||
kg = json_response["knowledge_graph"]
|
||||
knowledge_graph = {
|
||||
"title": kg.get("name", ""),
|
||||
"description": kg.get("description", ""),
|
||||
"type": kg.get("type", ""),
|
||||
}
|
||||
|
||||
extracted_search_result: Dict[str, Any] = {"organic": organic_results}
|
||||
|
||||
if knowledge_graph:
|
||||
extracted_search_result["knowledgeGraph"] = knowledge_graph
|
||||
|
||||
return query, extracted_search_result
|
||||
|
||||
|
||||
async def search_with_serper(query: str, location: LocationData) -> Tuple[str, Dict[str, List[Dict]]]:
|
||||
country_code = location.country_code.lower() if location and location.country_code else "us"
|
||||
payload = json.dumps({"q": query, "gl": country_code})
|
||||
|
||||
@@ -9,6 +9,7 @@ import uuid
|
||||
from typing import Any, Callable, List, Optional, Set, Union
|
||||
|
||||
import cron_descriptor
|
||||
import openai
|
||||
import pytz
|
||||
from apscheduler.job import Job
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
@@ -264,12 +265,21 @@ async def transcribe(
|
||||
if not speech_to_text_config:
|
||||
# If the user has not configured a speech to text model, return an unsupported on server error
|
||||
status_code = 501
|
||||
elif state.openai_client and speech_to_text_config.model_type == SpeechToTextModelOptions.ModelType.OPENAI:
|
||||
speech2text_model = speech_to_text_config.model_name
|
||||
user_message = await transcribe_audio(audio_file, speech2text_model, client=state.openai_client)
|
||||
elif speech_to_text_config.model_type == SpeechToTextModelOptions.ModelType.OFFLINE:
|
||||
speech2text_model = speech_to_text_config.model_name
|
||||
user_message = await transcribe_audio_offline(audio_filename, speech2text_model)
|
||||
elif speech_to_text_config.model_type == SpeechToTextModelOptions.ModelType.OPENAI:
|
||||
speech2text_model = speech_to_text_config.model_name
|
||||
if speech_to_text_config.ai_model_api:
|
||||
api_key = speech_to_text_config.ai_model_api.api_key
|
||||
api_base_url = speech_to_text_config.ai_model_api.api_base_url
|
||||
openai_client = openai.OpenAI(api_key=api_key, base_url=api_base_url)
|
||||
elif state.openai_client:
|
||||
openai_client = state.openai_client
|
||||
if openai_client:
|
||||
user_message = await transcribe_audio(audio_file, speech2text_model, client=openai_client)
|
||||
else:
|
||||
status_code = 501
|
||||
finally:
|
||||
# Close and Delete the temporary audio file
|
||||
audio_file.close()
|
||||
@@ -392,7 +402,8 @@ async def extract_references_and_questions(
|
||||
|
||||
filters_in_query += " ".join([f'file:"{filter}"' for filter in conversation.file_filters])
|
||||
using_offline_chat = False
|
||||
logger.debug(f"Filters in query: {filters_in_query}")
|
||||
if is_none_or_empty(filters_in_query):
|
||||
logger.debug(f"Filters in query: {filters_in_query}")
|
||||
|
||||
personality_context = prompts.personality_context.format(personality=agent.personality) if agent else ""
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ from khoj.routers.helpers import (
|
||||
construct_automation_created_message,
|
||||
create_automation,
|
||||
gather_raw_query_files,
|
||||
generate_excalidraw_diagram,
|
||||
generate_mermaidjs_diagram,
|
||||
generate_summary_from_files,
|
||||
get_conversation_command,
|
||||
is_query_empty,
|
||||
@@ -781,20 +781,25 @@ async def chat(
|
||||
|
||||
generated_images: List[str] = []
|
||||
generated_files: List[FileAttachment] = []
|
||||
generated_excalidraw_diagram: str = None
|
||||
generated_mermaidjs_diagram: str = None
|
||||
program_execution_context: List[str] = []
|
||||
|
||||
if conversation_commands == [ConversationCommand.Default]:
|
||||
chosen_io = await aget_data_sources_and_output_format(
|
||||
q,
|
||||
meta_log,
|
||||
is_automated_task,
|
||||
user=user,
|
||||
query_images=uploaded_images,
|
||||
agent=agent,
|
||||
query_files=attached_file_context,
|
||||
tracer=tracer,
|
||||
)
|
||||
try:
|
||||
chosen_io = await aget_data_sources_and_output_format(
|
||||
q,
|
||||
meta_log,
|
||||
is_automated_task,
|
||||
user=user,
|
||||
query_images=uploaded_images,
|
||||
agent=agent,
|
||||
query_files=attached_file_context,
|
||||
tracer=tracer,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(f"Error getting data sources and output format: {e}. Falling back to default.")
|
||||
conversation_commands = [ConversationCommand.General]
|
||||
|
||||
conversation_commands = chosen_io.get("sources") + [chosen_io.get("output")]
|
||||
|
||||
# If we're doing research, we don't want to do anything else
|
||||
@@ -1156,7 +1161,7 @@ async def chat(
|
||||
inferred_queries = []
|
||||
diagram_description = ""
|
||||
|
||||
async for result in generate_excalidraw_diagram(
|
||||
async for result in generate_mermaidjs_diagram(
|
||||
q=defiltered_query,
|
||||
conversation_history=meta_log,
|
||||
location_data=location,
|
||||
@@ -1172,12 +1177,12 @@ async def chat(
|
||||
if isinstance(result, dict) and ChatEvent.STATUS in result:
|
||||
yield result[ChatEvent.STATUS]
|
||||
else:
|
||||
better_diagram_description_prompt, excalidraw_diagram_description = result
|
||||
if better_diagram_description_prompt and excalidraw_diagram_description:
|
||||
better_diagram_description_prompt, mermaidjs_diagram_description = result
|
||||
if better_diagram_description_prompt and mermaidjs_diagram_description:
|
||||
inferred_queries.append(better_diagram_description_prompt)
|
||||
diagram_description = excalidraw_diagram_description
|
||||
diagram_description = mermaidjs_diagram_description
|
||||
|
||||
generated_excalidraw_diagram = diagram_description
|
||||
generated_mermaidjs_diagram = diagram_description
|
||||
|
||||
generated_asset_results["diagrams"] = {
|
||||
"query": better_diagram_description_prompt,
|
||||
@@ -1186,7 +1191,7 @@ async def chat(
|
||||
async for result in send_event(
|
||||
ChatEvent.GENERATED_ASSETS,
|
||||
{
|
||||
"excalidrawDiagram": excalidraw_diagram_description,
|
||||
"mermaidjsDiagram": mermaidjs_diagram_description,
|
||||
},
|
||||
):
|
||||
yield result
|
||||
@@ -1226,7 +1231,7 @@ async def chat(
|
||||
raw_query_files,
|
||||
generated_images,
|
||||
generated_files,
|
||||
generated_excalidraw_diagram,
|
||||
generated_mermaidjs_diagram,
|
||||
program_execution_context,
|
||||
generated_asset_results,
|
||||
tracer,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
import markdown_it
|
||||
import resend
|
||||
@@ -29,7 +30,7 @@ def is_resend_enabled():
|
||||
|
||||
|
||||
async def send_magic_link_email(email, unique_id, host):
|
||||
sign_in_link = f"{host}auth/magic?code={unique_id}&email={email}"
|
||||
sign_in_link = f"{host}auth/magic?code={quote(unique_id)}&email={quote(email)}"
|
||||
|
||||
if not is_resend_enabled():
|
||||
logger.debug(f"Email sending disabled. Share this sign-in link with the user: {sign_in_link}")
|
||||
|
||||
@@ -97,6 +97,7 @@ from khoj.processor.conversation.utils import (
|
||||
ChatEvent,
|
||||
ThreadedGenerator,
|
||||
clean_json,
|
||||
clean_mermaidjs,
|
||||
construct_chat_history,
|
||||
generate_chatml_messages_with_context,
|
||||
save_to_conversation_log,
|
||||
@@ -823,7 +824,7 @@ async def generate_better_diagram_description(
|
||||
elif online_results[result].get("webpages"):
|
||||
simplified_online_results[result] = online_results[result]["webpages"]
|
||||
|
||||
improve_diagram_description_prompt = prompts.improve_diagram_description_prompt.format(
|
||||
improve_diagram_description_prompt = prompts.improve_excalidraw_diagram_description_prompt.format(
|
||||
query=q,
|
||||
chat_history=chat_history,
|
||||
location=location,
|
||||
@@ -887,6 +888,133 @@ async def generate_excalidraw_diagram_from_description(
|
||||
return response
|
||||
|
||||
|
||||
async def generate_mermaidjs_diagram(
|
||||
q: str,
|
||||
conversation_history: Dict[str, Any],
|
||||
location_data: LocationData,
|
||||
note_references: List[Dict[str, Any]],
|
||||
online_results: Optional[dict] = None,
|
||||
query_images: List[str] = None,
|
||||
user: KhojUser = None,
|
||||
agent: Agent = None,
|
||||
send_status_func: Optional[Callable] = None,
|
||||
query_files: str = None,
|
||||
tracer: dict = {},
|
||||
):
|
||||
if send_status_func:
|
||||
async for event in send_status_func("**Enhancing the Diagramming Prompt**"):
|
||||
yield {ChatEvent.STATUS: event}
|
||||
|
||||
better_diagram_description_prompt = await generate_better_mermaidjs_diagram_description(
|
||||
q=q,
|
||||
conversation_history=conversation_history,
|
||||
location_data=location_data,
|
||||
note_references=note_references,
|
||||
online_results=online_results,
|
||||
query_images=query_images,
|
||||
user=user,
|
||||
agent=agent,
|
||||
query_files=query_files,
|
||||
tracer=tracer,
|
||||
)
|
||||
|
||||
if send_status_func:
|
||||
async for event in send_status_func(f"**Diagram to Create:**:\n{better_diagram_description_prompt}"):
|
||||
yield {ChatEvent.STATUS: event}
|
||||
|
||||
mermaidjs_diagram_description = await generate_mermaidjs_diagram_from_description(
|
||||
q=better_diagram_description_prompt,
|
||||
user=user,
|
||||
agent=agent,
|
||||
tracer=tracer,
|
||||
)
|
||||
|
||||
inferred_queries = f"Instruction: {better_diagram_description_prompt}"
|
||||
|
||||
yield inferred_queries, mermaidjs_diagram_description
|
||||
|
||||
|
||||
async def generate_better_mermaidjs_diagram_description(
|
||||
q: str,
|
||||
conversation_history: Dict[str, Any],
|
||||
location_data: LocationData,
|
||||
note_references: List[Dict[str, Any]],
|
||||
online_results: Optional[dict] = None,
|
||||
query_images: List[str] = None,
|
||||
user: KhojUser = None,
|
||||
agent: Agent = None,
|
||||
query_files: str = None,
|
||||
tracer: dict = {},
|
||||
) -> str:
|
||||
"""
|
||||
Generate a diagram description from the given query and context
|
||||
"""
|
||||
|
||||
today_date = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d, %A")
|
||||
personality_context = (
|
||||
prompts.personality_context.format(personality=agent.personality) if agent and agent.personality else ""
|
||||
)
|
||||
|
||||
location = f"{location_data}" if location_data else "Unknown"
|
||||
|
||||
user_references = "\n\n".join([f"# {item['compiled']}" for item in note_references])
|
||||
|
||||
chat_history = construct_chat_history(conversation_history)
|
||||
|
||||
simplified_online_results = {}
|
||||
|
||||
if online_results:
|
||||
for result in online_results:
|
||||
if online_results[result].get("answerBox"):
|
||||
simplified_online_results[result] = online_results[result]["answerBox"]
|
||||
elif online_results[result].get("webpages"):
|
||||
simplified_online_results[result] = online_results[result]["webpages"]
|
||||
|
||||
improve_diagram_description_prompt = prompts.improve_mermaid_js_diagram_description_prompt.format(
|
||||
query=q,
|
||||
chat_history=chat_history,
|
||||
location=location,
|
||||
current_date=today_date,
|
||||
references=user_references,
|
||||
online_results=simplified_online_results,
|
||||
personality_context=personality_context,
|
||||
)
|
||||
|
||||
with timer("Chat actor: Generate better Mermaid.js diagram description", logger):
|
||||
response = await send_message_to_model_wrapper(
|
||||
improve_diagram_description_prompt,
|
||||
query_images=query_images,
|
||||
user=user,
|
||||
query_files=query_files,
|
||||
tracer=tracer,
|
||||
)
|
||||
response = response.strip()
|
||||
if response.startswith(('"', "'")) and response.endswith(('"', "'")):
|
||||
response = response[1:-1]
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def generate_mermaidjs_diagram_from_description(
|
||||
q: str,
|
||||
user: KhojUser = None,
|
||||
agent: Agent = None,
|
||||
tracer: dict = {},
|
||||
) -> str:
|
||||
personality_context = (
|
||||
prompts.personality_context.format(personality=agent.personality) if agent and agent.personality else ""
|
||||
)
|
||||
|
||||
mermaidjs_diagram_generation = prompts.mermaid_js_diagram_generation_prompt.format(
|
||||
personality_context=personality_context,
|
||||
query=q,
|
||||
)
|
||||
|
||||
with timer("Chat actor: Generate Mermaid.js diagram", logger):
|
||||
raw_response = await send_message_to_model_wrapper(query=mermaidjs_diagram_generation, user=user, tracer=tracer)
|
||||
return clean_mermaidjs(raw_response.strip())
|
||||
|
||||
|
||||
async def generate_better_image_prompt(
|
||||
q: str,
|
||||
conversation_history: str,
|
||||
@@ -1224,7 +1352,7 @@ def generate_chat_response(
|
||||
raw_query_files: List[FileAttachment] = None,
|
||||
generated_images: List[str] = None,
|
||||
raw_generated_files: List[FileAttachment] = [],
|
||||
generated_excalidraw_diagram: str = None,
|
||||
generated_mermaidjs_diagram: str = None,
|
||||
program_execution_context: List[str] = [],
|
||||
generated_asset_results: Dict[str, Dict] = {},
|
||||
tracer: dict = {},
|
||||
@@ -1252,7 +1380,7 @@ def generate_chat_response(
|
||||
raw_query_files=raw_query_files,
|
||||
generated_images=generated_images,
|
||||
raw_generated_files=raw_generated_files,
|
||||
generated_excalidraw_diagram=generated_excalidraw_diagram,
|
||||
generated_mermaidjs_diagram=generated_mermaidjs_diagram,
|
||||
tracer=tracer,
|
||||
)
|
||||
|
||||
@@ -1785,7 +1913,7 @@ def scheduled_chat(
|
||||
raw_response = requests.post(url, headers=headers, json=json_payload, allow_redirects=False)
|
||||
|
||||
# Handle redirect manually if necessary
|
||||
if raw_response.status_code in [301, 302]:
|
||||
if raw_response.status_code in [301, 302, 308]:
|
||||
redirect_url = raw_response.headers["Location"]
|
||||
logger.info(f"Redirecting to {redirect_url}")
|
||||
raw_response = requests.post(redirect_url, headers=headers, json=json_payload)
|
||||
@@ -1967,7 +2095,7 @@ class MessageProcessor:
|
||||
self.raw_response = ""
|
||||
self.generated_images = []
|
||||
self.generated_files = []
|
||||
self.generated_excalidraw_diagram = []
|
||||
self.generated_mermaidjs_diagram = []
|
||||
|
||||
def convert_message_chunk_to_json(self, raw_chunk: str) -> Dict[str, Any]:
|
||||
if raw_chunk.startswith("{") and raw_chunk.endswith("}"):
|
||||
@@ -2014,8 +2142,8 @@ class MessageProcessor:
|
||||
self.generated_images = chunk_data[key]
|
||||
elif key == "files":
|
||||
self.generated_files = chunk_data[key]
|
||||
elif key == "excalidrawDiagram":
|
||||
self.generated_excalidraw_diagram = chunk_data[key]
|
||||
elif key == "mermaidjsDiagram":
|
||||
self.generated_mermaidjs_diagram = chunk_data[key]
|
||||
|
||||
def handle_json_response(self, json_data: Dict[str, str]) -> str | Dict[str, str]:
|
||||
if "image" in json_data or "details" in json_data:
|
||||
@@ -2052,7 +2180,7 @@ async def read_chat_stream(response_iterator: AsyncGenerator[str, None]) -> Dict
|
||||
"usage": processor.usage,
|
||||
"images": processor.generated_images,
|
||||
"files": processor.generated_files,
|
||||
"excalidrawDiagram": processor.generated_excalidraw_diagram,
|
||||
"mermaidjsDiagram": processor.generated_mermaidjs_diagram,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ default_offline_chat_models = [
|
||||
"bartowski/Llama-3.2-3B-Instruct-GGUF",
|
||||
"bartowski/gemma-2-9b-it-GGUF",
|
||||
"bartowski/gemma-2-2b-it-GGUF",
|
||||
"Qwen/Qwen2.5-14B-Instruct-GGUF",
|
||||
"bartowski/Qwen2.5-14B-Instruct-GGUF",
|
||||
]
|
||||
default_openai_chat_models = ["gpt-4o-mini", "gpt-4o"]
|
||||
default_gemini_chat_models = ["gemini-1.5-flash", "gemini-1.5-pro"]
|
||||
|
||||
@@ -355,7 +355,7 @@ command_descriptions = {
|
||||
|
||||
command_descriptions_for_agent = {
|
||||
ConversationCommand.General: "Agent can use the agents knowledge base and general knowledge.",
|
||||
ConversationCommand.Notes: "Agent can search the users knowledge base for information.",
|
||||
ConversationCommand.Notes: "Agent can search the personal knowledge base for information, as well as its own.",
|
||||
ConversationCommand.Online: "Agent can search the internet for information.",
|
||||
ConversationCommand.Webpage: "Agent can read suggested web pages for information.",
|
||||
ConversationCommand.Summarize: "Agent can read an entire document. Agents knowledge base must be a single document.",
|
||||
|
||||
@@ -43,14 +43,14 @@ def initialization(interactive: bool = True):
|
||||
"🗣️ Configure chat models available to your server. You can always update these at /server/admin using your admin account"
|
||||
)
|
||||
|
||||
openai_api_base = os.getenv("OPENAI_API_BASE")
|
||||
provider = "Ollama" if openai_api_base and openai_api_base.endswith(":11434/v1/") else "OpenAI"
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY", "placeholder" if openai_api_base else None)
|
||||
openai_base_url = os.getenv("OPENAI_BASE_URL")
|
||||
provider = "Ollama" if openai_base_url and openai_base_url.endswith(":11434/v1/") else "OpenAI"
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY", "placeholder" if openai_base_url else None)
|
||||
default_chat_models = default_openai_chat_models
|
||||
if openai_api_base:
|
||||
if openai_base_url:
|
||||
# Get available chat models from OpenAI compatible API
|
||||
try:
|
||||
openai_client = openai.OpenAI(api_key=openai_api_key, base_url=openai_api_base)
|
||||
openai_client = openai.OpenAI(api_key=openai_api_key, base_url=openai_base_url)
|
||||
default_chat_models = [model.id for model in openai_client.models.list()]
|
||||
# Put the available default OpenAI models at the top
|
||||
valid_default_models = [model for model in default_openai_chat_models if model in default_chat_models]
|
||||
@@ -66,7 +66,7 @@ def initialization(interactive: bool = True):
|
||||
ChatModel.ModelType.OPENAI,
|
||||
default_chat_models,
|
||||
default_api_key=openai_api_key,
|
||||
api_base_url=openai_api_base,
|
||||
api_base_url=openai_base_url,
|
||||
vision_enabled=True,
|
||||
is_offline=False,
|
||||
interactive=interactive,
|
||||
@@ -267,7 +267,7 @@ def initialization(interactive: bool = True):
|
||||
)
|
||||
|
||||
# Remove models that are no longer available
|
||||
existing_models.exclude(chat_model__in=available_models).delete()
|
||||
existing_models.exclude(name__in=available_models).delete()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update models for {config.name}: {str(e)}")
|
||||
|
||||
@@ -108,5 +108,6 @@
|
||||
"1.32.2": "0.15.0",
|
||||
"1.33.0": "0.15.0",
|
||||
"1.33.1": "0.15.0",
|
||||
"1.33.2": "0.15.0"
|
||||
"1.33.2": "0.15.0",
|
||||
"1.34.0": "0.15.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user