Merge pull request #682 from khoj-ai/features/full-integration-agents

Add support for custom agents configured by the server admin
This commit is contained in:
sabaimran 2024-03-27 23:27:15 -07:00 committed by GitHub
commit 22014cfcbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1609 additions and 119 deletions

View file

@ -1,4 +1,5 @@
FROM ubuntu:jammy
FROM nvidia/cuda:12.2.0-devel-ubuntu22.04
# FROM ubuntu:jammy
LABEL org.opencontainers.image.source https://github.com/khoj-ai/khoj

View file

@ -21,6 +21,7 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import HTTPConnection
from khoj.database.adapters import (
AgentAdapters,
ClientApplicationAdapters,
ConversationAdapters,
SubscriptionState,
@ -229,11 +230,16 @@ def configure_server(
state.SearchType = configure_search_types()
state.search_models = configure_search(state.search_models, state.config.search_type)
setup_default_agent()
initialize_content(regenerate, search_type, init, user)
except Exception as e:
raise e
def setup_default_agent():
AgentAdapters.create_default_agent()
def initialize_content(regenerate: bool, search_type: Optional[SearchType] = None, init=False, user: KhojUser = None):
# Initialize Content from Config
if state.search_models:
@ -262,6 +268,7 @@ def initialize_content(regenerate: bool, search_type: Optional[SearchType] = Non
def configure_routes(app):
# Import APIs here to setup search types before while configuring server
from khoj.routers.api import api
from khoj.routers.api_agents import api_agents
from khoj.routers.api_chat import api_chat
from khoj.routers.api_config import api_config
from khoj.routers.indexer import indexer
@ -269,6 +276,7 @@ def configure_routes(app):
app.include_router(api, prefix="/api")
app.include_router(api_chat, prefix="/api/chat")
app.include_router(api_agents, prefix="/api/agents")
app.include_router(api_config, prefix="/api/config")
app.include_router(indexer, prefix="/api/v1/index")
app.include_router(web_client)

View file

@ -16,6 +16,7 @@ from pgvector.django import CosineDistance
from torch import Tensor
from khoj.database.models import (
Agent,
ChatModelOptions,
ClientApplication,
Conversation,
@ -37,6 +38,7 @@ from khoj.database.models import (
UserRequests,
UserSearchModelConfig,
)
from khoj.processor.conversation import prompts
from khoj.search_filter.date_filter import DateFilter
from khoj.search_filter.file_filter import FileFilter
from khoj.search_filter.word_filter import WordFilter
@ -391,6 +393,78 @@ class ClientApplicationAdapters:
return await ClientApplication.objects.filter(client_id=client_id, client_secret=client_secret).afirst()
class AgentAdapters:
DEFAULT_AGENT_NAME = "Khoj"
DEFAULT_AGENT_AVATAR = "https://khoj-web-bucket.s3.amazonaws.com/lamp-128.png"
DEFAULT_AGENT_SLUG = "khoj"
@staticmethod
async def aget_agent_by_slug(agent_slug: str, user: KhojUser):
return await Agent.objects.filter(
(Q(slug__iexact=agent_slug.lower())) & (Q(public=True) | Q(creator=user))
).afirst()
@staticmethod
def get_agent_by_slug(slug: str, user: KhojUser = None):
if user:
return Agent.objects.filter((Q(slug__iexact=slug.lower())) & (Q(public=True) | Q(creator=user))).first()
return Agent.objects.filter(slug__iexact=slug.lower(), public=True).first()
@staticmethod
def get_all_accessible_agents(user: KhojUser = None):
if user:
return Agent.objects.filter(Q(public=True) | Q(creator=user)).distinct().order_by("created_at")
return Agent.objects.filter(public=True).order_by("created_at")
@staticmethod
async def aget_all_accessible_agents(user: KhojUser = None) -> List[Agent]:
agents = await sync_to_async(AgentAdapters.get_all_accessible_agents)(user)
return await sync_to_async(list)(agents)
@staticmethod
def get_conversation_agent_by_id(agent_id: int):
agent = Agent.objects.filter(id=agent_id).first()
if agent == AgentAdapters.get_default_agent():
# If the agent is set to the default agent, then return None and let the default application code be used
return None
return agent
@staticmethod
def get_default_agent():
return Agent.objects.filter(name=AgentAdapters.DEFAULT_AGENT_NAME).first()
@staticmethod
def create_default_agent():
default_conversation_config = ConversationAdapters.get_default_conversation_config()
default_personality = prompts.personality.format(current_date="placeholder")
agent = Agent.objects.filter(name=AgentAdapters.DEFAULT_AGENT_NAME).first()
if agent:
agent.personality = default_personality
agent.chat_model = default_conversation_config
agent.slug = AgentAdapters.DEFAULT_AGENT_SLUG
agent.name = AgentAdapters.DEFAULT_AGENT_NAME
agent.save()
return agent
# The default agent is public and managed by the admin. It's handled a little differently than other agents.
return Agent.objects.create(
name=AgentAdapters.DEFAULT_AGENT_NAME,
public=True,
managed_by_admin=True,
chat_model=default_conversation_config,
personality=default_personality,
tools=["*"],
avatar=AgentAdapters.DEFAULT_AGENT_AVATAR,
slug=AgentAdapters.DEFAULT_AGENT_SLUG,
)
@staticmethod
async def aget_default_agent():
return await Agent.objects.filter(name=AgentAdapters.DEFAULT_AGENT_NAME).afirst()
class ConversationAdapters:
@staticmethod
def get_conversation_by_user(
@ -403,9 +477,10 @@ class ConversationAdapters:
.first()
)
else:
agent = AgentAdapters.get_default_agent()
conversation = (
Conversation.objects.filter(user=user, client=client_application).order_by("-updated_at").first()
) or Conversation.objects.create(user=user, client=client_application)
) or Conversation.objects.create(user=user, client=client_application, agent=agent)
return conversation
@ -431,8 +506,16 @@ class ConversationAdapters:
return Conversation.objects.filter(id=conversation_id).first()
@staticmethod
async def acreate_conversation_session(user: KhojUser, client_application: ClientApplication = None):
return await Conversation.objects.acreate(user=user, client=client_application)
async def acreate_conversation_session(
user: KhojUser, client_application: ClientApplication = None, agent_slug: str = None
):
if agent_slug:
agent = await AgentAdapters.aget_agent_by_slug(agent_slug, user)
if agent is None:
raise HTTPException(status_code=400, detail="No such agent currently exists.")
return await Conversation.objects.acreate(user=user, client=client_application, agent=agent)
agent = await AgentAdapters.aget_default_agent()
return await Conversation.objects.acreate(user=user, client=client_application, agent=agent)
@staticmethod
async def aget_conversation_by_user(
@ -443,9 +526,14 @@ class ConversationAdapters:
elif title:
return await Conversation.objects.filter(user=user, client=client_application, title=title).afirst()
else:
return await (
Conversation.objects.filter(user=user, client=client_application).order_by("-updated_at").afirst()
) or await Conversation.objects.acreate(user=user, client=client_application)
conversation = Conversation.objects.filter(user=user, client=client_application).order_by("-updated_at")
if await conversation.aexists():
return await conversation.prefetch_related("agent").afirst()
return await (
Conversation.objects.filter(user=user, client=client_application).order_by("-updated_at").afirst()
) or await Conversation.objects.acreate(user=user, client=client_application)
@staticmethod
async def adelete_conversation_by_user(
@ -603,9 +691,14 @@ class ConversationAdapters:
return random.sample(all_questions, max_results)
@staticmethod
def get_valid_conversation_config(user: KhojUser):
def get_valid_conversation_config(user: KhojUser, conversation: Conversation):
offline_chat_config = ConversationAdapters.get_offline_chat_conversation_config()
conversation_config = ConversationAdapters.get_conversation_config(user)
if conversation.agent and conversation.agent.chat_model:
conversation_config = conversation.agent.chat_model
else:
conversation_config = ConversationAdapters.get_conversation_config(user)
if conversation_config is None:
conversation_config = ConversationAdapters.get_default_conversation_config()

View file

@ -6,6 +6,7 @@ from django.contrib.auth.admin import UserAdmin
from django.http import HttpResponse
from khoj.database.models import (
Agent,
ChatModelOptions,
ClientApplication,
Conversation,
@ -50,6 +51,7 @@ admin.site.register(ReflectiveQuestion)
admin.site.register(UserSearchModelConfig)
admin.site.register(TextToImageModelConfig)
admin.site.register(ClientApplication)
admin.site.register(Agent)
@admin.register(Entry)

View file

@ -0,0 +1,53 @@
# Generated by Django 4.2.10 on 2024-03-13 07:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0030_conversation_slug_and_title"),
]
operations = [
migrations.CreateModel(
name="Agent",
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)),
("name", models.CharField(max_length=200)),
("tuning", models.TextField()),
("avatar", models.URLField(blank=True, default=None, max_length=400, null=True)),
("tools", models.JSONField(default=list)),
("public", models.BooleanField(default=False)),
("managed_by_admin", models.BooleanField(default=False)),
("slug", models.CharField(max_length=200)),
(
"chat_model",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="database.chatmodeloptions"),
),
(
"creator",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="conversation",
name="agent",
field=models.ForeignKey(
blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to="database.agent"
),
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 4.2.10 on 2024-03-22 04:27
from typing import List
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("database", "0031_agent_conversation_agent"),
("database", "0031_alter_googleuser_locale"),
]
operations: List[str] = []

View file

@ -0,0 +1,17 @@
# Generated by Django 4.2.10 on 2024-03-23 16:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("database", "0032_merge_20240322_0427"),
]
operations = [
migrations.RenameField(
model_name="agent",
old_name="tuning",
new_name="personality",
),
]

View file

@ -1,7 +1,11 @@
import uuid
from random import choice
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.signals import pre_save
from django.dispatch import receiver
from pgvector.django import VectorField
from phonenumber_field.modelfields import PhoneNumberField
@ -69,6 +73,52 @@ class Subscription(BaseModel):
renewal_date = models.DateTimeField(null=True, default=None, blank=True)
class ChatModelOptions(BaseModel):
class ModelType(models.TextChoices):
OPENAI = "openai"
OFFLINE = "offline"
max_prompt_size = models.IntegerField(default=None, null=True, blank=True)
tokenizer = models.CharField(max_length=200, default=None, null=True, blank=True)
chat_model = models.CharField(max_length=200, default="mistral-7b-instruct-v0.1.Q4_0.gguf")
model_type = models.CharField(max_length=200, choices=ModelType.choices, default=ModelType.OFFLINE)
class Agent(BaseModel):
creator = models.ForeignKey(
KhojUser, on_delete=models.CASCADE, default=None, null=True, blank=True
) # Creator will only be null when the agents are managed by admin
name = models.CharField(max_length=200)
personality = models.TextField()
avatar = models.URLField(max_length=400, default=None, null=True, blank=True)
tools = models.JSONField(default=list) # List of tools the agent has access to, like online search or notes search
public = models.BooleanField(default=False)
managed_by_admin = models.BooleanField(default=False)
chat_model = models.ForeignKey(ChatModelOptions, on_delete=models.CASCADE)
slug = models.CharField(max_length=200)
@receiver(pre_save, sender=Agent)
def verify_agent(sender, instance, **kwargs):
# check if this is a new instance
if instance._state.adding:
if Agent.objects.filter(name=instance.name, public=True).exists():
raise ValidationError(f"A public Agent with the name {instance.name} already exists.")
if Agent.objects.filter(name=instance.name, creator=instance.creator).exists():
raise ValidationError(f"A private Agent with the name {instance.name} already exists.")
slug = instance.name.lower().replace(" ", "-")
observed_random_numbers = set()
while Agent.objects.filter(slug=slug).exists():
try:
random_number = choice([i for i in range(0, 1000) if i not in observed_random_numbers])
except IndexError:
raise ValidationError("Unable to generate a unique slug for the Agent. Please try again later.")
observed_random_numbers.add(random_number)
slug = f"{slug}-{random_number}"
instance.slug = slug
class NotionConfig(BaseModel):
token = models.CharField(max_length=200)
user = models.ForeignKey(KhojUser, on_delete=models.CASCADE)
@ -153,17 +203,6 @@ class SpeechToTextModelOptions(BaseModel):
model_type = models.CharField(max_length=200, choices=ModelType.choices, default=ModelType.OFFLINE)
class ChatModelOptions(BaseModel):
class ModelType(models.TextChoices):
OPENAI = "openai"
OFFLINE = "offline"
max_prompt_size = models.IntegerField(default=None, null=True, blank=True)
tokenizer = models.CharField(max_length=200, default=None, null=True, blank=True)
chat_model = models.CharField(max_length=200, default="mistral-7b-instruct-v0.1.Q4_0.gguf")
model_type = models.CharField(max_length=200, choices=ModelType.choices, default=ModelType.OFFLINE)
class UserConversationConfig(BaseModel):
user = models.OneToOneField(KhojUser, on_delete=models.CASCADE)
setting = models.ForeignKey(ChatModelOptions, on_delete=models.CASCADE, default=None, null=True, blank=True)
@ -180,6 +219,7 @@ class Conversation(BaseModel):
client = models.ForeignKey(ClientApplication, on_delete=models.CASCADE, default=None, null=True, blank=True)
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)
class ReflectiveQuestion(BaseModel):

View file

@ -2,14 +2,19 @@
<html>
<head>
<title>Khoj: An AI Personal Assistant for your digital brain</title>
<link rel=”stylesheet” href=”static/styles.css”>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<link rel="icon" type="image/png" sizes="128x128" href="/static/assets/icons/favicon-128x128.png?v={{ khoj_version }}">
<link rel="manifest" href="/static/khoj.webmanifest?v={{ khoj_version }}">
<link rel="stylesheet" href="/static/assets/khoj.css?v={{ khoj_version }}">
</head>
<body class="not-found">
<!--Add Header Logo and Nav Pane-->
{% import 'utils.html' as utils %}
{{ utils.heading_pane(user_photo, username, is_active, has_documents) }}
<header class=”header”>
<h1>Oops, this is awkward. That page couldn't be found.</h1>
<h1>Oops, this is awkward. Looks like there's nothing here.</h1>
</header>
<a href="/config">Go Home</a>
<a class="redirect-link" href="/">Go Home</a>
<footer class=”footer”>
</footer>
@ -18,5 +23,34 @@
body.not-found {
padding: 0 10%
}
body {
background-color: var(--background-color);
color: var(--main-text-color);
text-align: center;
font-family: var(--font-family);
font-size: medium;
font-weight: 300;
line-height: 1.5em;
height: 100vh;
margin: 0;
}
body a.redirect-link {
font-size: 18px;
font-weight: bold;
background-color: var(--primary);
text-decoration: none;
border: 1px solid var(--main-text-color);
color: var(--main-text-color);
border-radius: 8px;
padding: 4px;
}
body a.redirect-link:hover {
background-color: var(--main-text-color);
color: var(--primary);
}
</style>
</html>

View file

@ -0,0 +1,286 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0 maximum-scale=1.0">
<title>Khoj - Agents</title>
<link rel="icon" type="image/png" sizes="128x128" href="/static/assets/icons/favicon-128x128.png?v={{ khoj_version }}">
<link rel="manifest" href="/static/khoj.webmanifest?v={{ khoj_version }}">
<link rel="stylesheet" href="/static/assets/khoj.css?v={{ khoj_version }}">
</head>
<script type="text/javascript" src="/static/assets/utils.js?v={{ khoj_version }}"></script>
<body>
<!--Add Header Logo and Nav Pane-->
{% import 'utils.html' as utils %}
{{ utils.heading_pane(user_photo, username, is_active, has_documents) }}
<div id="agent-metadata-wrapper">
<div id="agent-metadata">
<div id="agent-avatar-wrapper">
<div id="agent-settings-header">Agent Settings</div>
</div>
<div class="divider"></div>
<div id="agent-data-wrapper">
<div id="agent-avatar-wrapper">
<img id="agent-avatar" src="{{ agent.avatar }}" alt="Agent Avatar">
<input type="text" id="agent-name-input" value="{{ agent.name }}" {% if agent.creator_not_self %} disabled {% endif %}>
</div>
<div id="agent-instructions">Instructions</div>
<div id="agent-tuning">
<p>{{ agent.personality }}</p>
</div>
<div class="divider"></div>
<div id="agent-public">
<p>Public</p>
<label class="switch">
<input type="checkbox" {% if agent.public %} checked {% endif %} {% if agent.creator_not_self %} disabled {% endif %}>
<span class="slider round"></span>
</label>
</div>
<p id="agent-creator" style="display: none;">Creator: {{ agent.creator }}</p>
<p id="agent-managed-by-admin" style="display: none;">ⓘ This agent is managed by the administrator</p>
<button onclick="openChat('{{ agent.slug }}')">Chat</button>
</div>
</div>
</div>
<div id="footer">
<a href="/agents">All Agents</a>
</div>
</body>
<style>
body {
background-color: var(--background-color);
display: grid;
color: var(--main-text-color);
text-align: center;
font-family: var(--font-family);
font-size: medium;
font-weight: 300;
line-height: 1.5em;
height: 100vh;
margin: 0;
grid-template-rows: auto 1fr auto;
}
div#agent-settings-header {
font-size: 24px;
font-weight: bold;
margin-top: auto;
margin-bottom: auto;
}
div.divider {
margin-top: 10px;
margin-bottom: 10px;
border-bottom: 2px solid var(--main-text-color);
}
div#footer {
width: auto;
padding: 10px;
background-color: var(--background-color);
border-top: 1px solid var(--main-text-color);
text-align: left;
margin-top: 12px;
margin-bottom: 12px;
}
div#footer a {
font-size: 18px;
font-weight: bold;
color: var(--primary-color);
}
div#agent-data-wrapper button {
font-size: 24px;
font-weight: bold;
padding: 10px;
border: none;
border-radius: 8px;
background-color: var(--primary);
font: inherit;
color: var(--main-text-color);
cursor: pointer;
transition: background-color 0.3s;
}
div#agent-data-wrapper button:hover {
background-color: var(--primary-hover);
box-shadow: 0 0 10px var(--primary-hover);
}
input#agent-name-input {
font-size: 24px;
font-weight: bold;
text-align: left;
background-color: #EEEEEE;
color: var(--main-text-color);
border-radius: 8px;
padding: 8px;
border: none;
}
div#agent-instructions {
font-size: 24px;
font-weight: bold;
}
#agent-metadata {
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
text-align: left;
padding: 20px;
}
#agent-avatar-wrapper {
margin-right: 10px;
display: flex;
flex-direction: row;
}
#agent-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
margin-right: 10px;
}
#agent-name {
font-size: 18px;
font-weight: bold;
color: #333;
}
#agent-tuning, #agent-public, #agent-creator, #agent-managed-by-admin {
font-size: 14px;
color: #666;
}
#agent-tuning p {
white-space: pre-line;
}
#agent-metadata p {
margin: 0;
padding: 0;
}
#agent-public {
display: grid;
grid-template-columns: auto 1fr;
grid-gap: 12px;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
}
input:checked + .slider {
background-color: var(--primary-hover);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--primary-hover);
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
div#agent-data-wrapper {
display: grid;
grid-template-columns: 1fr;
grid-gap: 10px;
text-align: left;
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
@media only screen and (min-width: 700px) {
body {
grid-template-columns: auto min(70vw, 100%) auto;
}
body > * {
grid-column: 2;
}
#agent-metadata-wrapper {
display: block;
width: min(30vw, 100%);
margin-left: auto;
margin-right: auto;
}
}
</style>
<script>
async function openChat(agentSlug) {
let response = await fetch(`/api/chat/sessions?agent_slug=${agentSlug}`, { method: "POST" });
let data = await response.json();
if (response.status == 200) {
window.location.href = "/";
} else {
alert("Failed to start chat session");
}
}
// Show the agent-managed-by-admin paragraph if the agent is managed by the admin
// compare agent.managed_by_admin as a lowercase string to "true"
let isManagedByAdmin = "{{ agent.managed_by_admin }}".toLowerCase() === "true";
if (isManagedByAdmin) {
document.getElementById("agent-managed-by-admin").style.display = "block";
} else {
document.getElementById("agent-creator").style.display = "block";
}
// Resize the input field based on the length of the value
let input = document.getElementById("agent-name-input");
input.addEventListener("input", resizeInput);
resizeInput.call(input);
function resizeInput() {
this.style.width = this.value.length + 1 + "ch";
}
</script>
</html>

View file

@ -0,0 +1,201 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0 maximum-scale=1.0">
<title>Khoj - Agents</title>
<link rel="icon" type="image/png" sizes="128x128" href="/static/assets/icons/favicon-128x128.png?v={{ khoj_version }}">
<link rel="manifest" href="/static/khoj.webmanifest?v={{ khoj_version }}">
<link rel="stylesheet" href="/static/assets/khoj.css?v={{ khoj_version }}">
</head>
<script type="text/javascript" src="/static/assets/utils.js?v={{ khoj_version }}"></script>
<body>
<!--Add Header Logo and Nav Pane-->
{% import 'utils.html' as utils %}
{{ utils.heading_pane(user_photo, username, is_active, has_documents) }}
<!-- {{ agents }} -->
<div id="agents-list">
<div id="agents">
<div id="agents-header">
<h1 id="agents-list-title">Agents</h1>
<!-- <div id="create-agent">
<a href="/agents/create"><svg class="new-convo-button" viewBox="0 0 35 35" fill="#000000" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0c-8.836 0-16 7.163-16 16s7.163 16 16 16c8.837 0 16-7.163 16-16s-7.163-16-16-16zM16 30.032c-7.72 0-14-6.312-14-14.032s6.28-14 14-14 14 6.28 14 14-6.28 14.032-14 14.032zM23 15h-6v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v6h-6c-0.552 0-1 0.448-1 1s0.448 1 1 1h6v6c0 0.552 0.448 1 1 1s1-0.448 1-1v-6h6c0.552 0 1-0.448 1-1s-0.448-1-1-1z"></path>
</svg></a>
</div> -->
</div>
{% for agent in agents %}
<div class="agent">
<a href="/agent/{{ agent.slug }}">
<div class="agent-avatar">
<img src="{{ agent.avatar }}" alt="{{ agent.name }}">
</div>
</a>
<div class="agent-info">
<a href="/agent/{{ agent.slug }}">
<h2>{{ agent.name }}</h2>
</a>
<p>{{ agent.personality }}</p>
</div>
<div class="agent-info">
<button onclick="openChat('{{ agent.slug }}')">Talk</button>
</div>
</div>
{% endfor %}
</div>
</div>
<div id="footer">
<a href="/">Back to Chat</a>
</div>
</body>
<style>
body {
background-color: var(--background-color);
display: grid;
color: var(--main-text-color);
text-align: center;
font-family: var(--font-family);
font-size: medium;
font-weight: 300;
line-height: 1.5em;
height: 100vh;
margin: 0;
grid-template-rows: auto 1fr auto;
}
h1#agents-list-title {
margin: 0;
}
.agent-info p {
height: 50px; /* Adjust this value as needed */
overflow: auto;
margin: 0px;
}
div.agent-info {
font-size: medium;
}
div.agent-info a,
div.agent-info h2 {
margin: 0;
}
div.agent img {
width: 50px;
border-radius: 50%;
}
div.agent a {
text-decoration: none;
color: var(--main-text-color);
}
div#agents-header {
display: grid;
grid-template-columns: auto;
}
div#agents-header a,
div.agent-info button {
font-size: 24px;
font-weight: bold;
padding: 10px;
border: none;
border-radius: 8px;
background-color: var(--primary);
font: inherit;
color: var(--main-text-color);
cursor: pointer;
transition: background-color 0.3s;
}
div#agents-header a:hover,
div.agent-info button:hover {
background-color: var(--primary-hover);
box-shadow: 0 0 10px var(--primary-hover);
}
div#footer {
width: auto;
padding: 10px;
background-color: var(--background-color);
border-top: 1px solid var(--main-text-color);
text-align: left;
margin-top: 12px;
margin-bottom: 12px;
}
div#footer a {
font-size: 18px;
font-weight: bold;
color: var(--primary-color);
}
div.agent {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 20px;
align-items: center;
padding: 20px;
background-color: var(--frosted-background-color);
border-top: 1px solid var(--main-text-color);
}
div.agent-info {
text-align: left;
}
div#agents {
display: grid;
grid-auto-flow: row;
gap: 20px;
padding: 20px;
background-color: var(--frosted-background-color);
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
border-radius: 8px;
width: 50%;
margin-right: auto;
margin-left: auto;
}
svg.new-convo-button {
width: 20px;
margin-left: 5px;
}
@media only screen and (min-width: 700px) {
body {
grid-template-columns: auto min(70vw, 100%) auto;
}
body > * {
grid-column: 2;
}
}
@media only screen and (max-width: 700px) {
div#agents {
width: 90%;
margin-right: auto;
margin-left: auto;
}
}
</style>
<script>
async function openChat(agentSlug) {
let response = await fetch(`/api/chat/sessions?agent_slug=${agentSlug}`, { method: "POST" });
let data = await response.json();
if (response.status == 200) {
window.location.href = "/";
} else if(response.status == 403 || response.status == 401) {
window.location.href = "/login?next=/agent/" + agentId;
} else {
alert("Failed to start chat session");
}
}
</script>
</html>

View file

@ -130,7 +130,7 @@ img.khoj-logo {
background-color: var(--background-color);
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
right: 15vw;
right: 5vw;
top: 64px;
z-index: 1;
opacity: 0;

View file

@ -162,7 +162,7 @@
height: 40px;
}
.card-title {
font-size: 20px;
font-size: medium;
font-weight: normal;
margin: 0;
padding: 0;

View file

@ -13,15 +13,17 @@
<script type="text/javascript" src="/static/assets/markdown-it.min.js?v={{ khoj_version }}"></script>
<script>
let welcome_message = `
Hi, I am Khoj, your open, personal AI 👋🏽. I can help:
Hi, I am Khoj, your open, personal AI 👋🏽. I can:
- 🧠 Answer general knowledge questions
- 💡 Be a sounding board for your ideas
- 📜 Chat with your notes & documents
- 🌄 Generate images based on your messages
- 🔎 Search the web for answers to your questions
- 🎙️ Listen to your audio messages (use the mic by the input box to speak your message)
- 📚 Understand files you drag & drop here
- 👩🏾‍🚀 Be tuned to your conversation needs via [agents](./agents)
Get the Khoj [Desktop](https://khoj.dev/downloads), [Obsidian](https://docs.khoj.dev/clients/obsidian#setup), [Emacs](https://docs.khoj.dev/clients/emacs#setup) apps to search, chat with your 🖥️ computer docs.
Get the Khoj [Desktop](https://khoj.dev/downloads), [Obsidian](https://docs.khoj.dev/clients/obsidian#setup), [Emacs](https://docs.khoj.dev/clients/emacs#setup) apps to search, chat with your 🖥️ computer docs. You can manage all the files you've shared with me at any time by going to [your settings](/config/content-source/computer/).
To get started, just start typing below. You can also type / to see a list of commands.
`.trim()
@ -116,7 +118,6 @@ To get started, just start typing below. You can also type / to see a list of co
linkElement.setAttribute('href', link);
linkElement.setAttribute('target', '_blank');
linkElement.setAttribute('rel', 'noopener noreferrer');
linkElement.classList.add("inline-chat-link");
linkElement.classList.add("reference-link");
linkElement.setAttribute('title', title);
linkElement.textContent = title;
@ -842,6 +843,33 @@ To get started, just start typing below. You can also type / to see a list of co
chatBody.dataset.conversationId = response.conversation_id;
chatBody.dataset.conversationTitle = response.slug || `New conversation 🌱`;
let agentMetadata = response.agent;
if (agentMetadata) {
let agentName = agentMetadata.name;
let agentAvatar = agentMetadata.avatar;
let agentOwnedByUser = agentMetadata.isCreator;
let agentAvatarElement = document.getElementById("agent-avatar");
let agentNameElement = document.getElementById("agent-name");
let agentLinkElement = document.getElementById("agent-link");
agentAvatarElement.src = agentAvatar;
agentNameElement.textContent = agentName;
agentLinkElement.setAttribute("href", `/agent/${agentMetadata.slug}`);
if (agentOwnedByUser) {
let agentOwnedByUserElement = document.getElementById("agent-owned-by-user");
agentOwnedByUserElement.style.display = "block";
}
let agentMetadataElement = document.getElementById("agent-metadata");
agentMetadataElement.style.display = "block";
} else {
let agentMetadataElement = document.getElementById("agent-metadata");
agentMetadataElement.style.display = "none";
}
let chatBodyWrapper = document.getElementById("chat-body-wrapper");
const fullChatLog = response.chat || [];
@ -934,12 +962,100 @@ To get started, just start typing below. You can also type / to see a list of co
}
function createNewConversation() {
let chatBody = document.getElementById("chat-body");
chatBody.innerHTML = "";
flashStatusInChatInput("📝 New conversation started");
chatBody.dataset.conversationId = "";
chatBody.dataset.conversationTitle = "";
renderMessage(welcome_message, "khoj");
// Create a modal that appears in the middle of the entire screen. It should have a form to create a new conversation.
let modal = document.createElement('div');
modal.classList.add("modal");
modal.id = "new-conversation-modal";
let modalContent = document.createElement('div');
modalContent.classList.add("modal-content");
let modalHeader = document.createElement('div');
modalHeader.classList.add("modal-header");
let modalTitle = document.createElement('h2');
modalTitle.textContent = "New Conversation";
let modalCloseButton = document.createElement('button');
modalCloseButton.classList.add("modal-close-button");
modalCloseButton.innerHTML = "&times;";
modalCloseButton.addEventListener('click', function() {
modal.remove();
});
modalHeader.appendChild(modalTitle);
modalHeader.appendChild(modalCloseButton);
modalContent.appendChild(modalHeader);
let modalBody = document.createElement('div');
modalBody.classList.add("modal-body");
let agentDropDownPicker = document.createElement('select');
agentDropDownPicker.setAttribute("id", "agent-dropdown-picker");
agentDropDownPicker.setAttribute("name", "agent-dropdown-picker");
let agentDropDownLabel = document.createElement('label');
agentDropDownLabel.setAttribute("for", "agent-dropdown-picker");
agentDropDownLabel.textContent = "Who do you want to talk to?";
fetch('/api/agents')
.then(response => response.json())
.then(data => {
if (data.length > 0) {
data.forEach((agent) => {
let agentOption = document.createElement('option');
agentOption.setAttribute("value", agent.slug);
agentOption.textContent = agent.name;
agentDropDownPicker.appendChild(agentOption);
});
}
})
.catch(err => {
return;
});
let seeAllAgentsLink = document.createElement('a');
seeAllAgentsLink.setAttribute("href", "/agents");
seeAllAgentsLink.setAttribute("target", "_blank");
seeAllAgentsLink.textContent = "See all agents";
let newConversationSubmitButton = document.createElement('button');
newConversationSubmitButton.setAttribute("type", "submit");
newConversationSubmitButton.textContent = "Go";
newConversationSubmitButton.id = "new-conversation-submit-button";
newConversationSubmitButton.addEventListener('click', function(event) {
event.preventDefault();
let agentSlug = agentDropDownPicker.value;
let createURL = `/api/chat/sessions?client=web&agent_slug=${agentSlug}`;
let chatBody = document.getElementById("chat-body");
fetch(createURL, { method: "POST" })
.then(response => response.json())
.then(data => {
chatBody.dataset.conversationId = data.conversation_id;
modal.remove();
loadChat();
})
.catch(err => {
return;
});
});
let closeButton = document.createElement('button');
closeButton.id = "close-button";
closeButton.innerHTML = "Close";
closeButton.classList.add("close-button");
closeButton.addEventListener('click', function() {
modal.remove();
});
modalBody.appendChild(agentDropDownLabel);
modalBody.appendChild(agentDropDownPicker);
modalBody.appendChild(seeAllAgentsLink);
let modalFooter = document.createElement('div');
modalFooter.classList.add("modal-footer");
modalFooter.appendChild(closeButton);
modalFooter.appendChild(newConversationSubmitButton);
modalBody.appendChild(modalFooter);
modalContent.appendChild(modalBody);
modal.appendChild(modalContent);
document.body.appendChild(modal);
}
function refreshChatSessionsPanel() {
@ -1194,8 +1310,6 @@ To get started, just start typing below. You can also type / to see a list of co
document.getElementById('new-conversation').classList.toggle('collapsed');
document.getElementById('existing-conversations').classList.toggle('collapsed');
document.getElementById('side-panel-collapse').style.transform = document.getElementById('side-panel').classList.contains('collapsed') ? 'rotate(0deg)' : 'rotate(180deg)';
document.getElementById('chat-section-wrapper').classList.toggle('mobile-friendly');
}
</script>
<body>
@ -1215,13 +1329,27 @@ To get started, just start typing below. You can also type / to see a list of co
<path d="M16 0c-8.836 0-16 7.163-16 16s7.163 16 16 16c8.837 0 16-7.163 16-16s-7.163-16-16-16zM16 30.032c-7.72 0-14-6.312-14-14.032s6.28-14 14-14 14 6.28 14 14-6.28 14.032-14 14.032zM23 15h-6v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v6h-6c-0.552 0-1 0.448-1 1s0.448 1 1 1h6v6c0 0.552 0.448 1 1 1s1-0.448 1-1v-6h6c0.552 0 1-0.448 1-1s-0.448-1-1-1z"></path>
</svg>
</button>
<div id="conversation-list-header" style="display: none;">Conversations</div>
</div>
<div id="existing-conversations">
<div id="conversation-list">
<div id="conversation-list-header" style="display: none;">Recent Conversations</div>
<div id="conversation-list-body"></div>
</div>
</div>
<a class="inline-chat-link" id="agent-link" href="">
<div id="agent-metadata" style="display: none;">
Active
<div id="agent-metadata-content">
<div id="agent-avatar-wrapper">
<img id="agent-avatar" src="" alt="Agent Avatar" />
</div>
<div id="agent-name-wrapper">
<div id="agent-name"></div>
<div id="agent-owned-by-user" style="display: none;">Edit</div>
</div>
</div>
</div>
</a>
</div>
<div id="collapse-side-panel">
<button
@ -1297,7 +1425,7 @@ To get started, just start typing below. You can also type / to see a list of co
color: var(--main-text-color);
text-align: center;
font-family: var(--font-family);
font-size: 20px;
font-size: medium;
font-weight: 300;
line-height: 1.5em;
height: 100vh;
@ -1448,10 +1576,6 @@ To get started, just start typing below. You can also type / to see a list of co
overflow-y: scroll;
}
#chat-section-wrapper.mobile-friendly {
grid-template-columns: auto auto;
}
#chat-body-wrapper {
display: flex;
flex-direction: column;
@ -1464,10 +1588,15 @@ To get started, just start typing below. You can also type / to see a list of co
background: var(--background-color);
border-radius: 5px;
box-shadow: 0 0 11px #aaa;
overflow-y: scroll;
text-align: left;
transition: width 0.3s ease-in-out;
max-height: 85vh;
max-height: 100%;
display: grid;
grid-template-rows: auto 1fr auto;
}
div#existing-conversations {
max-height: 95%;
overflow-y: auto;
}
@ -1489,8 +1618,12 @@ To get started, just start typing below. You can also type / to see a list of co
grid-gap: 8px;
}
div#conversation-list {
height: 1;
}
div#side-panel-wrapper {
display: flex
display: flex;
}
#chat-body {
@ -1880,7 +2013,7 @@ To get started, just start typing below. You can also type / to see a list of co
}
@media only screen and (min-width: 700px) {
body {
grid-template-columns: auto min(70vw, 100%) auto;
grid-template-columns: auto min(90vw, 100%) auto;
grid-template-rows: auto auto minmax(80px, 100%) auto;
}
body > * {
@ -1901,6 +2034,7 @@ To get started, just start typing below. You can also type / to see a list of co
div#new-conversation {
text-align: left;
border-bottom: 1px solid var(--main-text-color);
margin-top: 8px;
margin-bottom: 8px;
}
@ -2056,6 +2190,170 @@ To get started, just start typing below. You can also type / to see a list of co
animation-delay: -0.5s;
}
#agent-metadata-content {
display: grid;
grid-template-columns: auto 1fr;
padding: 10px;
background-color: var(--primary);
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
margin-bottom: 20px;
}
#agent-metadata {
border-top: 1px solid black;
padding-top: 10px;
}
#agent-avatar-wrapper {
margin-right: 10px;
}
#agent-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
}
#agent-name-wrapper {
display: grid;
align-items: center;
}
#agent-name {
font-size: 18px;
font-weight: bold;
color: #333;
}
#agent-instructions {
font-size: 14px;
color: #666;
height: 50px;
overflow: auto;
}
#agent-owned-by-user {
font-size: 12px;
color: #007BFF;
margin-top: 5px;
}
.modal {
position: fixed; /* Stay in place */
z-index: 1; /* Sit on top */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
margin: 0px;
}
.modal-content {
margin: 15% auto; /* 15% from the top and centered */
padding: 20px;
border: 1px solid #888;
width: 250px;
text-align: left;
background: var(--background-color);
border-radius: 5px;
box-shadow: 0 0 11px #aaa;
text-align: left;
}
.modal-header {
display: grid;
grid-template-columns: 1fr auto;
color: var(--main-text-color);
align-items: baseline;
}
.modal-header h2 {
margin: 0;
text-align: left;
}
.modal-body {
display: grid;
grid-auto-flow: row;
gap: 8px;
}
.modal-body a {
/* text-decoration: none; */
color: var(--summer-sun);
}
.modal-close-button {
margin: 0;
font-size: 20px;
background: none;
border: none;
color: var(--summer-sun);
}
.modal-close-button:hover,
.modal-close-button:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}
#new-conversation-form {
display: flex;
flex-direction: column;
}
#new-conversation-form label,
#new-conversation-form input,
#new-conversation-form button {
margin-bottom: 10px;
}
#new-conversation-form button {
cursor: pointer;
}
.modal-footer {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 12px;
}
.modal-body button {
cursor: pointer;
border-radius: 12px;
padding: 8px;
border: 1px solid var(--main-text-color);
}
button#new-conversation-submit-button {
background: var(--summer-sun);
transition: background 0.2s ease-in-out;
}
button#close-button {
background: var(--background-color);
transition: background 0.2s ease-in-out;
}
button#new-conversation-submit-button:hover {
background: var(--primary);
}
button#close-button:hover {
background: var(--primary-hover);
}
.modal-body select {
padding: 8px;
border-radius: 12px;
border: 1px solid var(--main-text-color);
}
@keyframes lds-ripple {
0% {
top: 36px;

View file

@ -4,31 +4,33 @@
<img class="khoj-logo" src="/static/assets/icons/khoj-logo-sideways-500.png?v={{ khoj_version }}" alt="Khoj"></img>
</a>
<nav class="khoj-nav">
<a id="agents-nav" class="khoj-nav" href="/agents">Agents</a>
{% if has_documents %}
<a id="chat-nav" class="khoj-nav" href="/chat">💬 Chat</a>
<a id="search-nav" class="khoj-nav" href="/search">🔎 Search</a>
<a id="search-nav" class="khoj-nav" href="/search">Search</a>
{% endif %}
<!-- Dropdown Menu -->
<div id="khoj-nav-menu-container" class="khoj-nav dropdown">
{% if user_photo and user_photo != "None" %}
{% if is_active %}
<img id="profile-picture" class="circle subscribed" src="{{ user_photo }}" alt="{{ username[0].upper() }}" onclick="toggleMenu()" referrerpolicy="no-referrer">
{% if username %}
<div id="khoj-nav-menu-container" class="khoj-nav dropdown">
{% if user_photo and user_photo != "None" %}
{% if is_active %}
<img id="profile-picture" class="circle subscribed" src="{{ user_photo }}" alt="{{ username[0].upper() }}" onclick="toggleMenu()" referrerpolicy="no-referrer">
{% else %}
<img id="profile-picture" class="circle" src="{{ user_photo }}" alt="{{ username[0].upper() }}" onclick="toggleMenu()" referrerpolicy="no-referrer">
{% endif %}
{% else %}
<img id="profile-picture" class="circle" src="{{ user_photo }}" alt="{{ username[0].upper() }}" onclick="toggleMenu()" referrerpolicy="no-referrer">
{% if is_active %}
<div id="profile-picture" class="circle user-initial subscribed" alt="{{ username[0].upper() }}" onclick="toggleMenu()">{{ username[0].upper() }}</div>
{% else %}
<div id="profile-picture" class="circle user-initial" alt="{{ username[0].upper() }}" onclick="toggleMenu()">{{ username[0].upper() }}</div>
{% endif %}
{% endif %}
{% else %}
{% if is_active %}
<div id="profile-picture" class="circle user-initial subscribed" alt="{{ username[0].upper() }}" onclick="toggleMenu()">{{ username[0].upper() }}</div>
{% else %}
<div id="profile-picture" class="circle user-initial" alt="{{ username[0].upper() }}" onclick="toggleMenu()">{{ username[0].upper() }}</div>
{% endif %}
{% endif %}
<div id="khoj-nav-menu" class="khoj-nav-dropdown-content">
<div class="khoj-nav-username"> {{ username }} </div>
<a id="settings-nav" class="khoj-nav" href="/config">⚙️ Settings</a>
<a class="khoj-nav" href="/auth/logout">🔑 Logout</a>
<div id="khoj-nav-menu" class="khoj-nav-dropdown-content">
<div class="khoj-nav-username"> {{ username }} </div>
<a id="settings-nav" class="khoj-nav" href="/config">⚙️ Settings</a>
<a class="khoj-nav" href="/auth/logout">🔑 Logout</a>
</div>
</div>
</div>
{% endif %}
</nav>
</div>
{%- endmacro %}

View file

@ -160,7 +160,9 @@ def start_server(app, host=None, port=None, socket=None):
if socket:
uvicorn.run(app, proxy_headers=True, uds=socket, log_level="debug", use_colors=True, log_config=None)
else:
uvicorn.run(app, host=host, port=port, log_level="debug", use_colors=True, log_config=None)
uvicorn.run(
app, host=host, port=port, log_level="debug", use_colors=True, log_config=None, timeout_keep_alive=60
)
logger.info("🌒 Stopping Khoj")

View file

@ -114,7 +114,7 @@ class MarkdownToEntries(TextToEntries):
# Append base filename to compiled entry for context to model
# Increment heading level for heading entries and make filename as its top level heading
prefix = f"# {stem}\n#" if heading else f"# {stem}\n"
compiled_entry = f"{prefix}{parsed_entry}"
compiled_entry = f"{entry_filename}\n{prefix}{parsed_entry}"
entries.append(
Entry(
compiled=compiled_entry,

View file

@ -6,6 +6,7 @@ from typing import Any, Iterator, List, Union
from langchain.schema import ChatMessage
from khoj.database.models import Agent
from khoj.processor.conversation import prompts
from khoj.processor.conversation.utils import (
ThreadedGenerator,
@ -141,6 +142,7 @@ def converse_offline(
tokenizer_name=None,
location_data: LocationData = None,
user_name: str = None,
agent: Agent = None,
) -> Union[ThreadedGenerator, Iterator[str]]:
"""
Converse with user using Llama
@ -156,6 +158,15 @@ def converse_offline(
# Initialize Variables
compiled_references_message = "\n\n".join({f"{item}" for item in references})
current_date = datetime.now().strftime("%Y-%m-%d")
if agent and agent.personality:
system_prompt = prompts.custom_system_prompt_message_gpt4all.format(
name=agent.name, bio=agent.personality, current_date=current_date
)
else:
system_prompt = prompts.system_prompt_message_gpt4all.format(current_date=current_date)
conversation_primer = prompts.query_prompt.format(query=user_query)
if location_data:
@ -185,10 +196,9 @@ def converse_offline(
conversation_primer = f"{prompts.notes_conversation_gpt4all.format(references=compiled_references_message)}\n{conversation_primer}"
# Setup Prompt with Primer or Conversation History
current_date = datetime.now().strftime("%Y-%m-%d")
messages = generate_chatml_messages_with_context(
conversation_primer,
prompts.system_prompt_message_gpt4all.format(current_date=current_date),
system_prompt,
conversation_log,
model_name=model,
max_prompt_size=max_prompt_size,

View file

@ -5,6 +5,7 @@ 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.openai.utils import (
chat_completion_with_backoff,
@ -115,6 +116,7 @@ def converse(
tokenizer_name=None,
location_data: LocationData = None,
user_name: str = None,
agent: Agent = None,
):
"""
Converse with user using OpenAI's ChatGPT
@ -125,6 +127,13 @@ def converse(
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)
@ -152,7 +161,7 @@ def converse(
# Setup Prompt with Primer or Conversation History
messages = generate_chatml_messages_with_context(
conversation_primer,
prompts.personality.format(current_date=current_date),
system_prompt,
conversation_log,
model,
max_prompt_size,

View file

@ -21,6 +21,24 @@ Today is {current_date} in UTC.
""".strip()
)
custom_personality = PromptTemplate.from_template(
"""
You are {name}, a personal agent on Khoj.
Use your general knowledge and past conversation with the user as context to inform your responses.
You were created by Khoj Inc. with the following capabilities:
- You *CAN REMEMBER ALL NOTES and PERSONAL INFORMATION FOREVER* that the user ever shares with you.
- Users can share files and other information with you using the Khoj Desktop, Obsidian or Emacs app. They can also drag and drop their files into the chat window.
- Say "I don't know" or "I don't understand" if you don't know what to say or if you don't know the answer to a question.
- Ask crisp follow-up questions to get additional context, when the answer cannot be inferred from the provided notes or past conversations.
- Sometimes the user will share personal information that needs to be remembered, like an account ID or a residential address. These can be acknowledged with a simple "Got it" or "Okay".
Today is {current_date} in UTC.
Instructions:\n{bio}
""".strip()
)
## General Conversation
## --
general_conversation = PromptTemplate.from_template(
@ -61,6 +79,20 @@ Today is {current_date} in UTC.
""".strip()
)
custom_system_prompt_message_gpt4all = PromptTemplate.from_template(
"""
You are {name}, a personal agent on Khoj.
- Use your general knowledge and past conversation with the user as context to inform your responses.
- If you do not know the answer, say 'I don't know.'
- Think step-by-step and ask questions to get the necessary information to answer the user's question.
- Do not print verbatim Notes unless necessary.
Today is {current_date} in UTC.
Instructions:\n{bio}
""".strip()
)
system_prompt_message_extract_questions_gpt4all = f"""You are Khoj, a kind and intelligent personal assistant. When the user asks you a question, you ask follow-up questions to clarify the necessary information you need in order to answer from the user's perspective.
- Write the question as if you can search for the answer on the user's personal notes.
- Try to be as specific as possible. Instead of saying "they" or "it" or "he", use the name of the person or thing you are referring to. For example, instead of saying "Which store did they go to?", say "Which store did Alice and Bob go to?".

View file

@ -13,7 +13,7 @@ from fastapi.requests import Request
from fastapi.responses import Response
from starlette.authentication import requires
from khoj.configure import configure_server, initialize_content
from khoj.configure import initialize_content
from khoj.database.adapters import (
ConversationAdapters,
EntryAdapters,

View file

@ -0,0 +1,43 @@
import json
import logging
from fastapi import APIRouter, Request
from fastapi.requests import Request
from fastapi.responses import Response
from khoj.database.adapters import AgentAdapters
from khoj.database.models import KhojUser
from khoj.routers.helpers import CommonQueryParams
# Initialize Router
logger = logging.getLogger(__name__)
api_agents = APIRouter()
@api_agents.get("/", response_class=Response)
async def all_agents(
request: Request,
common: CommonQueryParams,
) -> Response:
user: KhojUser = request.user.object if request.user.is_authenticated else None
agents = await AgentAdapters.aget_all_accessible_agents(user)
agents_packet = list()
for agent in agents:
agents_packet.append(
{
"slug": agent.slug,
"avatar": agent.avatar,
"name": agent.name,
"personality": agent.personality,
"public": agent.public,
"creator": agent.creator.username if agent.creator else None,
"managed_by_admin": agent.managed_by_admin,
}
)
# Make sure that the agent named 'khoj' is first in the list. Everything else is sorted by name.
agents_packet.sort(key=lambda x: x["name"])
agents_packet.sort(key=lambda x: x["slug"] == "khoj", reverse=True)
return Response(content=json.dumps(agents_packet), media_type="application/json", status_code=200)

View file

@ -12,7 +12,11 @@ from starlette.authentication import requires
from khoj.database.adapters import ConversationAdapters, EntryAdapters, aget_user_name
from khoj.database.models import KhojUser
from khoj.processor.conversation.prompts import help_message, no_entries_found
from khoj.processor.conversation.prompts import (
help_message,
no_entries_found,
no_notes_found,
)
from khoj.processor.conversation.utils import save_to_conversation_log
from khoj.processor.tools.online_search import (
online_search_enabled,
@ -85,9 +89,22 @@ def chat_history(
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}
{
"conversation_id": conversation.id,
"slug": conversation.title if conversation.title else conversation.slug,
"agent": agent_metadata,
}
)
update_telemetry_state(
@ -152,18 +169,24 @@ def chat_sessions(
async def create_chat_session(
request: Request,
common: CommonQueryParams,
agent_slug: Optional[str] = None,
):
user = request.user.object
# Create new Conversation Session
conversation = await ConversationAdapters.acreate_conversation_session(user, request.user.client_app)
conversation = await ConversationAdapters.acreate_conversation_session(user, request.user.client_app, agent_slug)
response = {"conversation_id": conversation.id}
conversation_metadata = {
"agent": agent_slug,
}
update_telemetry_state(
request=request,
telemetry_type="api",
api="create_chat_sessions",
metadata=conversation_metadata,
**common.__dict__,
)
@ -295,6 +318,14 @@ async def chat(
response_obj = {"response": no_entries_found_format}
return Response(content=json.dumps(response_obj), media_type="text/plain", status_code=200)
if conversation_commands == [ConversationCommand.Notes] and is_none_or_empty(compiled_references):
no_notes_found_format = no_notes_found.format()
if stream:
return StreamingResponse(iter([no_notes_found_format]), media_type="text/event-stream", status_code=200)
else:
response_obj = {"response": no_notes_found_format}
return Response(content=json.dumps(response_obj), media_type="text/plain", status_code=200)
if ConversationCommand.Notes in conversation_commands and is_none_or_empty(compiled_references):
conversation_commands.remove(ConversationCommand.Notes)
@ -356,6 +387,7 @@ async def chat(
llm_response, chat_metadata = await agenerate_chat_response(
defiltered_query,
meta_log,
conversation,
compiled_references,
online_results,
inferred_queries,

View file

@ -7,6 +7,7 @@ from starlette.authentication import requires
from starlette.config import Config
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse, Response
from starlette.status import HTTP_302_FOUND
from khoj.database.adapters import (
create_khoj_token,
@ -90,6 +91,7 @@ async def delete_token(request: Request, token: str) -> str:
@auth_router.post("/redirect")
async def auth(request: Request):
form = await request.form()
next_url = request.query_params.get("next", "/")
credential = form.get("credential")
csrf_token_cookie = request.cookies.get("g_csrf_token")
@ -117,9 +119,9 @@ async def auth(request: Request):
metadata={"user_id": str(khoj_user.uuid)},
)
logger.log(logging.INFO, f"New User Created: {khoj_user.uuid}")
RedirectResponse(url="/?status=welcome")
return RedirectResponse(url=f"{next_url}", status_code=HTTP_302_FOUND)
return RedirectResponse(url="/")
return RedirectResponse(url=f"{next_url}")
@auth_router.get("/logout")

View file

@ -10,10 +10,11 @@ import openai
from fastapi import Depends, Header, HTTPException, Request, UploadFile
from starlette.authentication import has_required_scope
from khoj.database.adapters import ConversationAdapters, EntryAdapters
from khoj.database.adapters import AgentAdapters, ConversationAdapters, EntryAdapters
from khoj.database.models import (
ChatModelOptions,
ClientApplication,
Conversation,
KhojUser,
Subscription,
TextToImageModelConfig,
@ -400,6 +401,7 @@ async def send_message_to_model_wrapper(
def generate_chat_response(
q: str,
meta_log: dict,
conversation: Conversation,
compiled_references: List[str] = [],
online_results: Dict[str, Dict] = {},
inferred_queries: List[str] = [],
@ -415,6 +417,7 @@ def generate_chat_response(
logger.debug(f"Conversation Types: {conversation_commands}")
metadata = {}
agent = AgentAdapters.get_conversation_agent_by_id(conversation.agent.id) if conversation.agent else None
try:
partial_completion = partial(
@ -429,7 +432,7 @@ def generate_chat_response(
conversation_id=conversation_id,
)
conversation_config = ConversationAdapters.get_valid_conversation_config(user)
conversation_config = ConversationAdapters.get_valid_conversation_config(user, conversation)
if conversation_config.model_type == "offline":
if state.gpt4all_processor_config is None or state.gpt4all_processor_config.loaded_model is None:
state.gpt4all_processor_config = GPT4AllProcessorModel(conversation_config.chat_model)
@ -448,6 +451,7 @@ def generate_chat_response(
tokenizer_name=conversation_config.tokenizer,
location_data=location_data,
user_name=user_name,
agent=agent,
)
elif conversation_config.model_type == "openai":
@ -467,6 +471,7 @@ def generate_chat_response(
tokenizer_name=conversation_config.tokenizer,
location_data=location_data,
user_name=user_name,
agent=agent,
)
metadata.update({"chat_model": conversation_config.chat_model})

View file

@ -10,6 +10,7 @@ from starlette.authentication import has_required_scope, requires
from khoj.database import adapters
from khoj.database.adapters import (
AgentAdapters,
ConversationAdapters,
EntryAdapters,
get_user_github_config,
@ -114,8 +115,8 @@ def chat_page(request: Request):
@web_client.get("/login", response_class=FileResponse)
def login_page(request: Request):
next_url = request.query_params.get("next", "/")
if request.user.is_authenticated:
next_url = request.query_params.get("next", "/")
return RedirectResponse(url=next_url)
google_client_id = os.environ.get("GOOGLE_CLIENT_ID")
redirect_uri = str(request.app.url_path_for("auth"))
@ -124,7 +125,85 @@ def login_page(request: Request):
context={
"request": request,
"google_client_id": google_client_id,
"redirect_uri": redirect_uri,
"redirect_uri": f"{redirect_uri}?next={next_url}",
},
)
@web_client.get("/agents", response_class=HTMLResponse)
def agents_page(request: Request):
user: KhojUser = request.user.object if request.user.is_authenticated else None
user_picture = request.session.get("user", {}).get("picture") if user else None
agents = AgentAdapters.get_all_accessible_agents(user)
agents_packet = list()
for agent in agents:
agents_packet.append(
{
"slug": agent.slug,
"avatar": agent.avatar,
"name": agent.name,
"personality": agent.personality,
"public": agent.public,
"creator": agent.creator.username if agent.creator else None,
"managed_by_admin": agent.managed_by_admin,
}
)
return templates.TemplateResponse(
"agents.html",
context={
"request": request,
"agents": agents_packet,
"khoj_version": state.khoj_version,
"username": user.username if user else None,
"has_documents": False,
"is_active": has_required_scope(request, ["premium"]),
"user_photo": user_picture,
},
)
@web_client.get("/agent/{agent_slug}", response_class=HTMLResponse)
def agent_page(request: Request, agent_slug: str):
user: KhojUser = request.user.object if request.user.is_authenticated else None
user_picture = request.session.get("user", {}).get("picture") if user else None
agent = AgentAdapters.get_agent_by_slug(agent_slug)
if agent == None:
return templates.TemplateResponse(
"404.html",
context={
"request": request,
"khoj_version": state.khoj_version,
"username": user.username if user else None,
"has_documents": False,
"is_active": has_required_scope(request, ["premium"]),
"user_photo": user_picture,
},
)
agent_metadata = {
"slug": agent.slug,
"avatar": agent.avatar,
"name": agent.name,
"personality": agent.personality,
"public": agent.public,
"creator": agent.creator.username if agent.creator else None,
"managed_by_admin": agent.managed_by_admin,
"chat_model": agent.chat_model.chat_model,
"creator_not_self": agent.creator != user,
}
return templates.TemplateResponse(
"agent.html",
context={
"request": request,
"agent": agent_metadata,
"khoj_version": state.khoj_version,
"username": user.username if user else None,
"has_documents": False,
"is_active": has_required_scope(request, ["premium"]),
"user_photo": user_picture,
},
)

View file

@ -12,6 +12,7 @@ from khoj.configure import (
configure_search_types,
)
from khoj.database.models import (
Agent,
GithubConfig,
GithubRepoConfig,
KhojApiUser,
@ -181,6 +182,28 @@ def api_user4(default_user4):
)
@pytest.mark.django_db
@pytest.fixture
def offline_agent():
chat_model = ChatModelOptionsFactory()
return Agent.objects.create(
name="Accountant",
chat_model=chat_model,
personality="You are a certified CPA. You are able to tell me how much I've spent based on my notes. Regardless of what I ask, you should always respond with the total amount I've spent. ALWAYS RESPOND WITH A SUMMARY TOTAL OF HOW MUCH MONEY I HAVE SPENT.",
)
@pytest.mark.django_db
@pytest.fixture
def openai_agent():
chat_model = ChatModelOptionsFactory(chat_model="gpt-3.5-turbo", model_type="openai")
return Agent.objects.create(
name="Accountant",
chat_model=chat_model,
personality="You are a certified CPA. You are able to tell me how much I've spent based on my notes. Regardless of what I ask, you should always respond with the total amount I've spent.",
)
@pytest.fixture(scope="session")
def search_models(search_config: SearchConfig):
search_models = SearchModels()

View file

@ -465,6 +465,47 @@ My sister, Aiyla is married to Tolga. They have 3 kids, Yildiz, Ali and Ahmet.""
)
# ----------------------------------------------------------------------------------------------------
@pytest.mark.chatquality
def test_agent_prompt_should_be_used(loaded_model, offline_agent):
"Chat actor should ask be tuned to think like an accountant based on the agent definition"
# Arrange
context = [
f"""I went to the store and bought some bananas for 2.20""",
f"""I went to the store and bought some apples for 1.30""",
f"""I went to the store and bought some oranges for 6.00""",
]
# Act
response_gen = converse_offline(
references=context, # Assume context retrieved from notes for the user_query
user_query="What did I buy?",
loaded_model=loaded_model,
)
response = "".join([response_chunk for response_chunk in response_gen])
# Assert that the model without the agent prompt does not include the summary of purchases
expected_responses = ["9.50", "9.5"]
assert all([expected_response not in response for expected_response in expected_responses]), (
"Expected chat actor to summarize values of purchases" + response
)
# Act
response_gen = converse_offline(
references=context, # Assume context retrieved from notes for the user_query
user_query="What did I buy?",
loaded_model=loaded_model,
agent=offline_agent,
)
response = "".join([response_chunk for response_chunk in response_gen])
# Assert that the model with the agent prompt does include the summary of purchases
expected_responses = ["9.50", "9.5"]
assert any([expected_response in response for expected_response in expected_responses]), (
"Expected chat actor to summarize values of purchases" + response
)
# ----------------------------------------------------------------------------------------------------
def test_chat_does_not_exceed_prompt_size(loaded_model):
"Ensure chat context and response together do not exceed max prompt size for the model"

View file

@ -6,6 +6,7 @@ import pytest
from faker import Faker
from freezegun import freeze_time
from khoj.database.models import Agent, KhojUser
from khoj.processor.conversation import prompts
from khoj.processor.conversation.utils import message_to_log
from khoj.routers.helpers import aget_relevant_information_sources
@ -26,20 +27,20 @@ def generate_history(message_list):
# Generate conversation logs
conversation_log = {"chat": []}
for user_message, gpt_message, context in message_list:
conversation_log["chat"] += message_to_log(
message_to_log(
user_message,
gpt_message,
{"context": context, "intent": {"query": user_message, "inferred-queries": f'["{user_message}"]'}},
conversation_log=conversation_log.get("chat", []),
)
return conversation_log
def populate_chat_history(message_list, user):
def create_conversation(message_list, user, agent=None):
# Generate conversation logs
conversation_log = generate_history(message_list)
# Update Conversation Metadata Logs in Database
ConversationFactory(user=user, conversation_log=conversation_log)
return ConversationFactory(user=user, conversation_log=conversation_log, agent=agent)
# Tests
@ -114,7 +115,7 @@ def test_answer_from_chat_history(client_offline_chat, default_user2):
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f'/api/chat?q="What is my name?"&stream=true')
@ -141,7 +142,7 @@ def test_answer_from_currently_retrieved_content(client_offline_chat, default_us
["Testatron was born on 1st April 1984 in Testville."],
),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f'/api/chat?q="Where was Xi Li born?"')
@ -165,7 +166,7 @@ def test_answer_from_chat_history_and_previously_retrieved_content(client_offlin
["Testatron was born on 1st April 1984 in Testville."],
),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f'/api/chat?q="Where was I born?"')
@ -191,7 +192,7 @@ def test_answer_from_chat_history_and_currently_retrieved_content(client_offline
("Hello, my name is Xi Li. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f'/api/chat?q="Where was I born?"')
@ -217,7 +218,7 @@ def test_no_answer_in_chat_history_or_retrieved_content(client_offline_chat, def
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f'/api/chat?q="Where was I born?"&stream=true')
@ -238,7 +239,7 @@ def test_answer_using_general_command(client_offline_chat, default_user2):
# Arrange
query = urllib.parse.quote("/general Where was Xi Li born?")
message_list = []
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f"/api/chat?q={query}&stream=true")
@ -256,7 +257,7 @@ def test_answer_from_retrieved_content_using_notes_command(client_offline_chat,
# Arrange
query = urllib.parse.quote("/notes Where was Xi Li born?")
message_list = []
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f"/api/chat?q={query}&stream=true")
@ -275,7 +276,7 @@ def test_answer_using_file_filter(client_offline_chat, default_user2):
no_answer_query = urllib.parse.quote('Where was Xi Li born? file:"Namita.markdown"')
answer_query = urllib.parse.quote('Where was Xi Li born? file:"Xi Li.markdown"')
message_list = []
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
no_answer_response = client_offline_chat.get(f"/api/chat?q={no_answer_query}&stream=true").content.decode("utf-8")
@ -293,7 +294,7 @@ def test_answer_not_known_using_notes_command(client_offline_chat, default_user2
# Arrange
query = urllib.parse.quote("/notes Where was Testatron born?")
message_list = []
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f"/api/chat?q={query}&stream=true")
@ -351,7 +352,7 @@ def test_answer_general_question_not_in_chat_history_or_retrieved_content(client
("When was I born?", "You were born on 1st April 1984.", []),
("Where was I born?", "You were born Testville.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(
@ -394,14 +395,14 @@ def test_ask_for_clarification_if_not_enough_context_in_question(client_offline_
@pytest.mark.xfail(reason="Chat director not capable of answering this question yet")
@pytest.mark.chatquality
@pytest.mark.django_db(transaction=True)
def test_answer_in_chat_history_beyond_lookback_window(client_offline_chat, default_user2):
def test_answer_in_chat_history_beyond_lookback_window(client_offline_chat, default_user2: KhojUser):
# Arrange
message_list = [
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
("Where was I born?", "You were born Testville.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f'/api/chat?q="What is my name?"&stream=true')
@ -415,13 +416,77 @@ def test_answer_in_chat_history_beyond_lookback_window(client_offline_chat, defa
)
# ----------------------------------------------------------------------------------------------------
@pytest.mark.chatquality
@pytest.mark.django_db(transaction=True)
def test_answer_in_chat_history_by_conversation_id(client_offline_chat, default_user2: KhojUser):
# Arrange
message_list = [
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
("What's my favorite color", "Your favorite color is green.", []),
("Where was I born?", "You were born Testville.", []),
]
message_list2 = [
("Hello, my name is Julia. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 14th August 1947.", []),
("What's my favorite color", "Your favorite color is maroon.", []),
("Where was I born?", "You were born in a potato farm.", []),
]
conversation = create_conversation(message_list, default_user2)
create_conversation(message_list2, default_user2)
# Act
response = client_offline_chat.get(
f'/api/chat?q="What is my favorite color?"&conversation_id={conversation.id}&stream=true'
)
response_message = response.content.decode("utf-8")
# Assert
expected_responses = ["green"]
assert response.status_code == 200
assert any([expected_response in response_message.lower() for expected_response in expected_responses]), (
"Expected green in response, but got: " + response_message
)
# ----------------------------------------------------------------------------------------------------
@pytest.mark.xfail(reason="Chat director not great at adhering to agent instructions yet")
@pytest.mark.chatquality
@pytest.mark.django_db(transaction=True)
def test_answer_in_chat_history_by_conversation_id_with_agent(
client_offline_chat, default_user2: KhojUser, offline_agent: Agent
):
# Arrange
message_list = [
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
("What's my favorite color", "Your favorite color is green.", []),
("Where was I born?", "You were born Testville.", []),
("What did I buy?", "You bought an apple for 2.00, an orange for 3.00, and a potato for 8.00", []),
]
conversation = create_conversation(message_list, default_user2, offline_agent)
# Act
query = urllib.parse.quote("/general What did I eat for breakfast?")
response = client_offline_chat.get(f"/api/chat?q={query}&conversation_id={conversation.id}&stream=true")
response_message = response.content.decode("utf-8")
# Assert that agent only responds with the summary of spending
expected_responses = ["13.00", "13", "13.0", "thirteen"]
assert response.status_code == 200
assert any([expected_response in response_message.lower() for expected_response in expected_responses]), (
"Expected green in response, but got: " + response_message
)
@pytest.mark.chatquality
@pytest.mark.django_db(transaction=True)
def test_answer_chat_history_very_long(client_offline_chat, default_user2):
# Arrange
message_list = [(" ".join([fake.paragraph() for _ in range(50)]), fake.sentence(), []) for _ in range(10)]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = client_offline_chat.get(f'/api/chat?q="What is my name?"&stream=true')
@ -514,7 +579,7 @@ async def test_get_correct_tools_general(client_offline_chat):
# ----------------------------------------------------------------------------------------------------
@pytest.mark.anyio
@pytest.mark.django_db(transaction=True)
async def test_get_correct_tools_with_chat_history(client_offline_chat):
async def test_get_correct_tools_with_chat_history(client_offline_chat, default_user2):
# Arrange
user_query = "What's the latest in the Israel/Palestine conflict?"
chat_log = [
@ -525,7 +590,7 @@ async def test_get_correct_tools_with_chat_history(client_offline_chat):
),
("What's up in New York City?", "A Pride parade has recently been held in New York City, on July 31st.", []),
]
chat_history = populate_chat_history(chat_log)
chat_history = create_conversation(chat_log, default_user2)
# Act
tools = await aget_relevant_information_sources(user_query, chat_history)

View file

@ -34,7 +34,9 @@ def test_markdown_file_with_no_headings_to_jsonl(tmp_path):
# Ensure raw entry with no headings do not get heading prefix prepended
assert not jsonl_data[0]["raw"].startswith("#")
# Ensure compiled entry has filename prepended as top level heading
assert jsonl_data[0]["compiled"].startswith(expected_heading)
assert expected_heading in jsonl_data[0]["compiled"]
# Ensure compiled entry also includes the file name
assert str(tmp_path) in jsonl_data[0]["compiled"]
def test_single_markdown_entry_to_jsonl(tmp_path):

View file

@ -414,6 +414,42 @@ My sister, Aiyla is married to Tolga. They have 3 kids, Yildiz, Ali and Ahmet.""
)
# ----------------------------------------------------------------------------------------------------
@pytest.mark.chatquality
def test_agent_prompt_should_be_used(openai_agent):
"Chat actor should ask be tuned to think like an accountant based on the agent definition"
# Arrange
context = [
f"""I went to the store and bought some bananas for 2.20""",
f"""I went to the store and bought some apples for 1.30""",
f"""I went to the store and bought some oranges for 6.00""",
]
expected_responses = ["9.50", "9.5"]
# Act
response_gen = converse(
references=context, # Assume context retrieved from notes for the user_query
user_query="What did I buy?",
api_key=api_key,
)
no_agent_response = "".join([response_chunk for response_chunk in response_gen])
response_gen = converse(
references=context, # Assume context retrieved from notes for the user_query
user_query="What did I buy?",
api_key=api_key,
agent=openai_agent,
)
agent_response = "".join([response_chunk for response_chunk in response_gen])
# Assert that the model without the agent prompt does not include the summary of purchases
assert all([expected_response not in no_agent_response for expected_response in expected_responses]), (
"Expected chat actor to summarize values of purchases" + no_agent_response
)
assert any([expected_response in agent_response for expected_response in expected_responses]), (
"Expected chat actor to summarize values of purchases" + agent_response
)
# ----------------------------------------------------------------------------------------------------
@pytest.mark.anyio
@pytest.mark.django_db(transaction=True)

View file

@ -5,13 +5,10 @@ from urllib.parse import quote
import pytest
from freezegun import freeze_time
from khoj.database.models import KhojUser
from khoj.database.models import Agent, KhojUser
from khoj.processor.conversation import prompts
from khoj.processor.conversation.utils import message_to_log
from khoj.routers.helpers import (
aget_relevant_information_sources,
aget_relevant_output_modes,
)
from khoj.routers.helpers import aget_relevant_information_sources
from tests.helpers import ConversationFactory
# Initialize variables for tests
@ -29,20 +26,21 @@ def generate_history(message_list):
# Generate conversation logs
conversation_log = {"chat": []}
for user_message, gpt_message, context in message_list:
conversation_log["chat"] += message_to_log(
message_to_log(
user_message,
gpt_message,
{"context": context, "intent": {"query": user_message, "inferred-queries": f'["{user_message}"]'}},
conversation_log=conversation_log.get("chat", []),
)
return conversation_log
def populate_chat_history(message_list, user):
def create_conversation(message_list, user, agent=None):
# Generate conversation logs
conversation_log = generate_history(message_list)
# Update Conversation Metadata Logs in Database
ConversationFactory(user=user, conversation_log=conversation_log)
return ConversationFactory(user=user, conversation_log=conversation_log, agent=agent)
# Tests
@ -116,7 +114,7 @@ def test_answer_from_chat_history(chat_client, default_user2: KhojUser):
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client.get(f'/api/chat?q="What is my name?"&stream=true')
@ -143,7 +141,7 @@ def test_answer_from_currently_retrieved_content(chat_client, default_user2: Kho
["Testatron was born on 1st April 1984 in Testville."],
),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client.get(f'/api/chat?q="Where was Xi Li born?"')
@ -167,7 +165,7 @@ def test_answer_from_chat_history_and_previously_retrieved_content(chat_client_n
["Testatron was born on 1st April 1984 in Testville."],
),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client_no_background.get(f'/api/chat?q="Where was I born?"')
@ -190,7 +188,7 @@ def test_answer_from_chat_history_and_currently_retrieved_content(chat_client, d
("Hello, my name is Xi Li. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client.get(f'/api/chat?q="Where was I born?"')
@ -215,7 +213,7 @@ def test_no_answer_in_chat_history_or_retrieved_content(chat_client, default_use
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client.get(f'/api/chat?q="Where was I born?"&stream=true')
@ -244,7 +242,7 @@ def test_answer_using_general_command(chat_client, default_user2: KhojUser):
# Arrange
query = urllib.parse.quote("/general Where was Xi Li born?")
message_list = []
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client.get(f"/api/chat?q={query}&stream=true")
@ -262,7 +260,7 @@ def test_answer_from_retrieved_content_using_notes_command(chat_client, default_
# Arrange
query = urllib.parse.quote("/notes Where was Xi Li born?")
message_list = []
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client.get(f"/api/chat?q={query}&stream=true")
@ -280,7 +278,7 @@ def test_answer_not_known_using_notes_command(chat_client_no_background, default
# Arrange
query = urllib.parse.quote("/notes Where was Testatron born?")
message_list = []
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client_no_background.get(f"/api/chat?q={query}&stream=true")
@ -335,7 +333,7 @@ def test_answer_general_question_not_in_chat_history_or_retrieved_content(chat_c
("When was I born?", "You were born on 1st April 1984.", []),
("Where was I born?", "You were born Testville.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client.get(f'/api/chat?q="Write a haiku about unit testing. Do not say anything else."&stream=true')
@ -387,7 +385,7 @@ def test_answer_in_chat_history_beyond_lookback_window(chat_client, default_user
("When was I born?", "You were born on 1st April 1984.", []),
("Where was I born?", "You were born Testville.", []),
]
populate_chat_history(message_list, default_user2)
create_conversation(message_list, default_user2)
# Act
response = chat_client.get(f'/api/chat?q="What is my name?"&stream=true')
@ -401,6 +399,68 @@ def test_answer_in_chat_history_beyond_lookback_window(chat_client, default_user
)
# ----------------------------------------------------------------------------------------------------
@pytest.mark.chatquality
@pytest.mark.django_db(transaction=True)
def test_answer_in_chat_history_by_conversation_id(chat_client, default_user2: KhojUser):
# Arrange
message_list = [
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
("What's my favorite color", "Your favorite color is green.", []),
("Where was I born?", "You were born Testville.", []),
]
message_list2 = [
("Hello, my name is Julia. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 14th August 1947.", []),
("What's my favorite color", "Your favorite color is maroon.", []),
("Where was I born?", "You were born in a potato farm.", []),
]
conversation = create_conversation(message_list, default_user2)
create_conversation(message_list2, default_user2)
# Act
query = urllib.parse.quote("/general What is my favorite color?")
response = chat_client.get(f"/api/chat?q={query}&conversation_id={conversation.id}&stream=true")
response_message = response.content.decode("utf-8")
# Assert
expected_responses = ["green"]
assert response.status_code == 200
assert any([expected_response in response_message.lower() for expected_response in expected_responses]), (
"Expected green in response, but got: " + response_message
)
# ----------------------------------------------------------------------------------------------------
@pytest.mark.chatquality
@pytest.mark.django_db(transaction=True)
def test_answer_in_chat_history_by_conversation_id_with_agent(
chat_client, default_user2: KhojUser, openai_agent: Agent
):
# Arrange
message_list = [
("Hello, my name is Testatron. Who are you?", "Hi, I am Khoj, a personal assistant. How can I help?", []),
("When was I born?", "You were born on 1st April 1984.", []),
("What's my favorite color", "Your favorite color is green.", []),
("Where was I born?", "You were born Testville.", []),
("What did I buy?", "You bought an apple for 2.00, an orange for 3.00, and a potato for 8.00", []),
]
conversation = create_conversation(message_list, default_user2, openai_agent)
# Act
query = urllib.parse.quote("/general What did I eat for breakfast?")
response = chat_client.get(f"/api/chat?q={query}&conversation_id={conversation.id}&stream=true")
response_message = response.content.decode("utf-8")
# Assert that agent only responds with the summary of spending
expected_responses = ["13.00", "13", "13.0", "thirteen"]
assert response.status_code == 200
assert any([expected_response in response_message.lower() for expected_response in expected_responses]), (
"Expected green in response, but got: " + response_message
)
# ----------------------------------------------------------------------------------------------------
@pytest.mark.django_db(transaction=True)
@pytest.mark.chatquality