Part 2: Add web UI updates for basic agent interactions (#675)

* Initial pass at backend changes to support agents
- Add a db model for Agents, attaching them to conversations
- When an agent is added to a conversation, override the system prompt to tweak the instructions
- Agents can be configured with prompt modification, model specification, a profile picture, and other things
- Admin-configured models will not be editable by individual users
- Add unit tests to verify agent behavior. Unit tests demonstrate imperfect adherence to prompt specifications

* Customize default behaviors for conversations without agents or with default agents

* Add a new web client route for viewing all agents

* Use agent_id for getting correct agent

* Add web UI views for agents
- Add a page to view all agents
- Add slugs to manage agents
- Add a view to view single agent
- Display active agent when in chat window
- Fix post-login redirect issue

* Fix agent view

* Spruce up the 404 page and improve the overall layout for agents pages

* Create chat actor for directly reading webpages based on user message

- Add prompt for the read webpages chat actor to extract, infer
  webpage links
- Make chat actor infer or extract webpage to read directly from user
  message
- Rename previous read_webpage function to more narrow
  read_webpage_at_url function

* Rename agents_page -> agent_page

* Fix unit test for adding the filename to the compiled markdown entry

* Fix layout of agent, agents pages

* Merge migrations

* Let the name, slug of the default agent be Khoj, khoj

* Fix chat-related unit tests

* Add webpage chat command for read web pages requested by user

Update auto chat command inference prompt to show example of when to
use webpage chat command (i.e when url is directly provided in link)

* Support webpage command in chat API

- Fallback to use webpage when SERPER not setup and online command was
  attempted
- Do not stop responding if can't retrieve online results. Try to
  respond without the online context

* Test select webpage as data source and extract web urls chat actors

* Tweak prompts to extract information from webpages, online results

- Show more of the truncated messages for debugging context
- Update Khoj personality prompt to encourage it to remember it's capabilities

* Rename extract_content online results field to webpages

* Parallelize simple webpage read and extractor

Similar to what is being done with search_online with olostep

* Pass multiple webpages with their urls in online results context

Previously even if MAX_WEBPAGES_TO_READ was > 1, only 1 extracted
content would ever be passed.

URL of the extracted webpage content wasn't passed to clients in
online results context. This limited them from being rendered

* Render webpage read in chat response references on Web, Desktop apps

* Time chat actor responses & chat api request start for perf analysis

* Increase the keep alive timeout in the main application for testing

* Do not pipe access/error logs to separate files. Flow to stdout/stderr

* [Temp] Reduce to 1 gunicorn worker

* Change prod docker image to use jammy, rather than nvidia base image

* Use Khoj icon when Khoj web is installed on iOS as a PWA

* Make slug required for agents

* Simplify calling logic and prevent agent access for unauthenticated users

* Standardize to use personality over tuning in agent nomenclature

* Make filtering logic more stringent for accessible agents and remove unused method:

* Format chat message query

---------

Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
This commit is contained in:
sabaimran 2024-03-26 05:43:24 -07:00 committed by GitHub
parent 8abc8ded82
commit fdf78525b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1332 additions and 130 deletions

View file

@ -1,10 +1,10 @@
import multiprocessing
bind = "0.0.0.0:42110"
workers = 8
workers = 1
worker_class = "uvicorn.workers.UvicornWorker"
timeout = 120
keep_alive = 60
accesslog = "access.log"
errorlog = "error.log"
accesslog = "-"
errorlog = "-"
loglevel = "debug"

View file

@ -1,12 +1,9 @@
# Use Nvidia's latest Ubuntu 22.04 image as the base image
FROM nvidia/cuda:12.2.0-devel-ubuntu22.04
FROM ubuntu:jammy
LABEL org.opencontainers.image.source https://github.com/khoj-ai/khoj
# Install System Dependencies
RUN apt update -y && apt -y install python3-pip libsqlite3-0 ffmpeg libsm6 libxext6
# Install Optional Dependencies
RUN apt install vim -y
WORKDIR /app

View file

@ -87,7 +87,7 @@
function generateOnlineReference(reference, index) {
// Generate HTML for Chat Reference
let title = reference.title;
let title = reference.title || reference.link;
let link = reference.link;
let snippet = reference.snippet;
let question = reference.question;
@ -191,6 +191,15 @@
referenceSection.appendChild(polishedReference);
}
}
if (onlineReference.webpages && onlineReference.webpages.length > 0) {
numOnlineReferences += onlineReference.webpages.length;
for (let index in onlineReference.webpages) {
let reference = onlineReference.webpages[index];
let polishedReference = generateOnlineReference(reference, index);
referenceSection.appendChild(polishedReference);
}
}
}
return numOnlineReferences;

View file

@ -268,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
@ -275,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

@ -394,20 +394,32 @@ class ClientApplicationAdapters:
class AgentAdapters:
DEFAULT_AGENT_NAME = "khoj"
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_id(agent_id: int, user: KhojUser):
agent = await Agent.objects.filter(id=agent_id).afirst()
# Check if it's accessible to the user
if agent and (agent.public or agent.creator == user):
return agent
return None
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):
return Agent.objects.filter(Q(public=True) | Q(creator=user)).distinct()
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):
@ -423,12 +435,19 @@ class AgentAdapters:
@staticmethod
def create_default_agent():
# First delete the existing default
Agent.objects.filter(name=AgentAdapters.DEFAULT_AGENT_NAME).delete()
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,
@ -438,6 +457,7 @@ class AgentAdapters:
personality=default_personality,
tools=["*"],
avatar=AgentAdapters.DEFAULT_AGENT_AVATAR,
slug=AgentAdapters.DEFAULT_AGENT_SLUG,
)
@staticmethod
@ -486,10 +506,12 @@ class ConversationAdapters:
@staticmethod
async def acreate_conversation_session(
user: KhojUser, client_application: ClientApplication = None, agent_id: int = None
user: KhojUser, client_application: ClientApplication = None, agent_slug: str = None
):
if agent_id:
agent = await AgentAdapters.aget_agent_by_id(agent_id, user)
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)
return await Conversation.objects.acreate(user=user, client=client_application)

View file

@ -1,4 +1,4 @@
# Generated by Django 4.2.10 on 2024-03-11 05:12
# Generated by Django 4.2.10 on 2024-03-13 07:38
import django.db.models.deletion
from django.conf import settings
@ -23,6 +23,7 @@ class Migration(migrations.Migration):
("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"),

View file

@ -1,4 +1,5 @@
import uuid
from random import choice
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
@ -94,13 +95,28 @@ class Agent(BaseModel):
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 check_public_name(sender, instance, **kwargs):
if instance.public:
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):

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

@ -5,6 +5,7 @@
<title>Khoj - Chat</title>
<link rel="icon" type="image/png" sizes="128x128" href="/static/assets/icons/favicon-128x128.png?v={{ khoj_version }}">
<link rel="apple-touch-icon" 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>
@ -12,15 +13,16 @@
<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
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()
@ -101,7 +103,7 @@ To get started, just start typing below. You can also type / to see a list of co
function generateOnlineReference(reference, index) {
// Generate HTML for Chat Reference
let title = reference.title;
let title = reference.title || reference.link;
let link = reference.link;
let snippet = reference.snippet;
let question = reference.question;
@ -115,7 +117,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;
@ -205,6 +206,15 @@ To get started, just start typing below. You can also type / to see a list of co
referenceSection.appendChild(polishedReference);
}
}
if (onlineReference.webpages && onlineReference.webpages.length > 0) {
numOnlineReferences += onlineReference.webpages.length;
for (let index in onlineReference.webpages) {
let reference = onlineReference.webpages[index];
let polishedReference = generateOnlineReference(reference, index);
referenceSection.appendChild(polishedReference);
}
}
}
return numOnlineReferences;
@ -832,6 +842,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 || [];
@ -924,12 +961,100 @@ To get started, just start typing below. You can also type / to see a list of co
}
function createNewConversation() {
// 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");
chatBody.innerHTML = "";
flashStatusInChatInput("📝 New conversation started");
chatBody.dataset.conversationId = "";
chatBody.dataset.conversationTitle = "";
renderMessage(welcome_message, "khoj");
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() {
@ -1184,8 +1309,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>
@ -1205,13 +1328,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
@ -1287,7 +1424,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;
@ -1438,10 +1575,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;
@ -1454,10 +1587,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;
}
@ -1479,8 +1617,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 {
@ -1870,7 +2012,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 > * {
@ -1891,6 +2033,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;
}
@ -2046,6 +2189,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

@ -5,6 +5,7 @@
<title>Khoj - Search</title>
<link rel="icon" type="image/png" sizes="128x128" href="/static/assets/icons/favicon-128x128.png?v={{ khoj_version }}">
<link rel="apple-touch-icon" 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>

View file

@ -9,6 +9,7 @@
<a id="search-nav" class="khoj-nav" href="/search">🔎 Search</a>
{% endif %}
<!-- Dropdown Menu -->
{% if username %}
<div id="khoj-nav-menu-container" class="khoj-nav dropdown">
{% if user_photo and user_photo != "None" %}
{% if is_active %}
@ -29,6 +30,7 @@
<a class="khoj-nav" href="/auth/logout">🔑 Logout</a>
</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

@ -188,8 +188,8 @@ def converse_offline(
if ConversationCommand.Online in conversation_commands:
simplified_online_results = online_results.copy()
for result in online_results:
if online_results[result].get("extracted_content"):
simplified_online_results[result] = online_results[result]["extracted_content"]
if online_results[result].get("webpages"):
simplified_online_results[result] = online_results[result]["webpages"]
conversation_primer = f"{prompts.online_search_conversation.format(online_results=str(simplified_online_results))}\n{conversation_primer}"
if not is_none_or_empty(compiled_references_message):

View file

@ -1,7 +1,7 @@
import json
import logging
from datetime import datetime, timedelta
from typing import Optional
from typing import Dict, Optional
from langchain.schema import ChatMessage
@ -105,7 +105,7 @@ def send_message_to_model(messages, api_key, model, response_type="text"):
def converse(
references,
user_query,
online_results: Optional[dict] = None,
online_results: Optional[Dict[str, Dict]] = None,
conversation_log={},
model: str = "gpt-3.5-turbo",
api_key: Optional[str] = None,
@ -151,7 +151,7 @@ def converse(
completion_func(chat_response=prompts.no_online_results_found.format())
return iter([prompts.no_online_results_found.format()])
if ConversationCommand.Online in conversation_commands:
if ConversationCommand.Online in conversation_commands or ConversationCommand.Webpage in conversation_commands:
conversation_primer = (
f"{prompts.online_search_conversation.format(online_results=str(online_results))}\n{conversation_primer}"
)
@ -167,7 +167,7 @@ def converse(
max_prompt_size,
tokenizer_name,
)
truncated_messages = "\n".join({f"{message.content[:40]}..." for message in messages})
truncated_messages = "\n".join({f"{message.content[:70]}..." for message in messages})
logger.debug(f"Conversation Context for GPT: {truncated_messages}")
# Get Response from GPT

View file

@ -10,7 +10,7 @@ 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.
- You can generate images, look-up information from the internet, and answer questions based on the user's notes.
- You *CAN* generate images, look-up real-time information from the internet, and answer questions based on the user's notes.
- You cannot set reminders.
- 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.
@ -23,7 +23,7 @@ Today is {current_date} in UTC.
custom_personality = PromptTemplate.from_template(
"""
Your are {name}, a personal agent on Khoj.
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:
@ -178,7 +178,8 @@ online_search_conversation = PromptTemplate.from_template(
Use this up-to-date information from the internet to inform your response.
Ask crisp follow-up questions to get additional context, when a helpful response cannot be provided from the online data or past conversations.
Information from the internet: {online_results}
Information from the internet:
{online_results}
""".strip()
)
@ -312,7 +313,7 @@ Target Query: {query}
Web Pages:
{corpus}
Collate the relevant information from the website to answer the target query.
Collate only relevant information from the website to answer the target query.
""".strip()
)
@ -394,6 +395,14 @@ AI: Good morning! How can I help you today?
Q: How can I share my files with Khoj?
Khoj: {{"source": ["default", "online"]}}
Example:
Chat History:
User: What is the first element in the periodic table?
AI: The first element in the periodic table is Hydrogen.
Q: Summarize this article https://en.wikipedia.org/wiki/Hydrogen
Khoj: {{"source": ["webpage"]}}
Example:
Chat History:
User: I want to start a new hobby. I'm thinking of learning to play the guitar.
@ -412,6 +421,50 @@ Khoj:
""".strip()
)
infer_webpages_to_read = PromptTemplate.from_template(
"""
You are Khoj, an advanced web page reading assistant. You are to construct **up to three, valid** webpage urls to read before answering the user's question.
- You will receive the conversation history as context.
- Add as much context from the previous questions and answers as required to construct the webpage urls.
- Use multiple web page urls if required to retrieve the relevant information.
- You have access to the the whole internet to retrieve information.
Which webpages will you need to read to answer the user's question?
Provide web page links as a list of strings in a JSON object.
Current Date: {current_date}
User's Location: {location}
Here are some examples:
History:
User: I like to use Hacker News to get my tech news.
AI: Hacker News is an online forum for sharing and discussing the latest tech news. It is a great place to learn about new technologies and startups.
Q: Summarize this post about vector database on Hacker News, https://news.ycombinator.com/item?id=12345
Khoj: {{"links": ["https://news.ycombinator.com/item?id=12345"]}}
History:
User: I'm currently living in New York but I'm thinking about moving to San Francisco.
AI: New York is a great city to live in. It has a lot of great restaurants and museums. San Francisco is also a great city to live in. It has good access to nature and a great tech scene.
Q: What is the climate like in those cities?
Khoj: {{"links": ["https://en.wikipedia.org/wiki/New_York_City", "https://en.wikipedia.org/wiki/San_Francisco"]}}
History:
User: Hey, how is it going?
AI: Not too bad. How can I help you today?
Q: What's the latest news on r/worldnews?
Khoj: {{"links": ["https://www.reddit.com/r/worldnews/"]}}
Now it's your turn to share actual webpage urls you'd like to read to answer the user's question.
History:
{chat_history}
Q: {query}
Khoj:
""".strip()
)
online_search_conversation_subqueries = PromptTemplate.from_template(
"""
You are Khoj, an advanced google search assistant. You are tasked with constructing **up to three** google search queries to answer the user's question.

View file

@ -2,6 +2,7 @@ import asyncio
import json
import logging
import os
from collections import defaultdict
from typing import Dict, Tuple, Union
import aiohttp
@ -9,7 +10,11 @@ import requests
from bs4 import BeautifulSoup
from markdownify import markdownify
from khoj.routers.helpers import extract_relevant_info, generate_online_subqueries
from khoj.routers.helpers import (
extract_relevant_info,
generate_online_subqueries,
infer_webpage_urls,
)
from khoj.utils.helpers import is_none_or_empty, timer
from khoj.utils.rawconfig import LocationData
@ -38,7 +43,7 @@ MAX_WEBPAGES_TO_READ = 1
async def search_online(query: str, conversation_history: dict, location: LocationData):
if SERPER_DEV_API_KEY is None:
if not online_search_enabled():
logger.warn("SERPER_DEV_API_KEY is not set")
return {}
@ -52,24 +57,21 @@ async def search_online(query: str, conversation_history: dict, location: Locati
# Gather distinct web pages from organic search results of each subquery without an instant answer
webpage_links = {
result["link"]
organic["link"]: subquery
for subquery in response_dict
for result in response_dict[subquery].get("organic", [])[:MAX_WEBPAGES_TO_READ]
for organic in response_dict[subquery].get("organic", [])[:MAX_WEBPAGES_TO_READ]
if "answerBox" not in response_dict[subquery]
}
# Read, extract relevant info from the retrieved web pages
tasks = []
for webpage_link in webpage_links:
logger.info(f"Reading web page at '{webpage_link}'")
task = read_webpage_and_extract_content(subquery, webpage_link)
tasks.append(task)
logger.info(f"Reading web pages at: {webpage_links.keys()}")
tasks = [read_webpage_and_extract_content(subquery, link) for link, subquery in webpage_links.items()]
results = await asyncio.gather(*tasks)
# Collect extracted info from the retrieved web pages
for subquery, extracted_webpage_content in results:
if extracted_webpage_content is not None:
response_dict[subquery]["extracted_content"] = extracted_webpage_content
for subquery, webpage_extract, url in results:
if webpage_extract is not None:
response_dict[subquery]["webpages"] = {"link": url, "snippet": webpage_extract}
return response_dict
@ -93,19 +95,35 @@ def search_with_google(subquery: str):
return extracted_search_result
async def read_webpage_and_extract_content(subquery: str, url: str) -> Tuple[str, Union[None, str]]:
async def read_webpages(query: str, conversation_history: dict, location: LocationData):
"Infer web pages to read from the query and extract relevant information from them"
logger.info(f"Inferring web pages to read")
urls = await infer_webpage_urls(query, conversation_history, location)
logger.info(f"Reading web pages at: {urls}")
tasks = [read_webpage_and_extract_content(query, url) for url in urls]
results = await asyncio.gather(*tasks)
response: Dict[str, Dict] = defaultdict(dict)
response[query]["webpages"] = [
{"query": q, "link": url, "snippet": web_extract} for q, web_extract, url in results if web_extract is not None
]
return response
async def read_webpage_and_extract_content(subquery: str, url: str) -> Tuple[str, Union[None, str], str]:
try:
with timer(f"Reading web page at '{url}' took", logger):
content = await read_webpage_with_olostep(url) if OLOSTEP_API_KEY else await read_webpage(url)
content = await read_webpage_with_olostep(url) if OLOSTEP_API_KEY else await read_webpage_at_url(url)
with timer(f"Extracting relevant information from web page at '{url}' took", logger):
extracted_info = await extract_relevant_info(subquery, content)
return subquery, extracted_info
return subquery, extracted_info, url
except Exception as e:
logger.error(f"Failed to read web page at '{url}' with {e}")
return subquery, None
return subquery, None, url
async def read_webpage(web_url: str) -> str:
async def read_webpage_at_url(web_url: str) -> str:
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36",
}
@ -129,3 +147,7 @@ async def read_webpage_with_olostep(web_url: str) -> str:
response.raise_for_status()
response_json = await response.json()
return response_json["markdown_content"]
def online_search_enabled():
return SERPER_DEV_API_KEY is not None

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,9 +12,17 @@ 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 search_online
from khoj.processor.tools.online_search import (
online_search_enabled,
read_webpages,
search_online,
)
from khoj.routers.api import extract_references_and_questions
from khoj.routers.helpers import (
ApiUserRateLimiter,
@ -81,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(
@ -148,12 +169,12 @@ def chat_sessions(
async def create_chat_session(
request: Request,
common: CommonQueryParams,
agent_id: Optional[int] = None,
agent_slug: Optional[str] = None,
):
user = request.user.object
# Create new Conversation Session
conversation = await ConversationAdapters.acreate_conversation_session(user, request.user.client_app, agent_id)
conversation = await ConversationAdapters.acreate_conversation_session(user, request.user.client_app, agent_slug)
response = {"conversation_id": conversation.id}
@ -239,6 +260,7 @@ async def chat(
) -> Response:
user: KhojUser = request.user.object
q = unquote(q)
logger.info(f"Chat request by {user.username}: {q}")
await is_ready_to_chat(user)
conversation_commands = [get_conversation_command(query=q, any_references=True)]
@ -281,7 +303,7 @@ async def chat(
compiled_references, inferred_queries, defiltered_query = await extract_references_and_questions(
request, common, meta_log, q, (n or 5), (d or math.inf), conversation_commands, location
)
online_results: Dict = dict()
online_results: Dict[str, Dict] = {}
if conversation_commands == [ConversationCommand.Notes] and not await EntryAdapters.auser_has_entries(user):
no_entries_found_format = no_entries_found.format()
@ -291,17 +313,35 @@ 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)
if ConversationCommand.Online in conversation_commands:
if not online_search_enabled():
conversation_commands.remove(ConversationCommand.Online)
# If online search is not enabled, try to read webpages directly
if ConversationCommand.Webpage not in conversation_commands:
conversation_commands.append(ConversationCommand.Webpage)
else:
try:
online_results = await search_online(defiltered_query, meta_log, location)
except ValueError as e:
return StreamingResponse(
iter(["Please set your SERPER_DEV_API_KEY to get started with online searches 🌐"]),
media_type="text/event-stream",
status_code=200,
logger.warning(f"Error searching online: {e}. Attempting to respond without online results")
if ConversationCommand.Webpage in conversation_commands:
try:
online_results = await read_webpages(defiltered_query, meta_log, location)
except ValueError as e:
logger.warning(
f"Error directly reading webpages: {e}. Attempting to respond without online results", exc_info=True
)
if ConversationCommand.Image in conversation_commands:

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

@ -37,6 +37,7 @@ from khoj.utils.config import GPT4AllProcessorModel
from khoj.utils.helpers import (
ConversationCommand,
is_none_or_empty,
is_valid_url,
log_telemetry,
mode_descriptions_for_llm,
timer,
@ -168,6 +169,7 @@ async def aget_relevant_information_sources(query: str, conversation_history: di
chat_history=chat_history,
)
with timer("Chat actor: Infer information sources to refer", logger):
response = await send_message_to_model_wrapper(relevant_tools_prompt, response_type="json_object")
try:
@ -212,6 +214,7 @@ async def aget_relevant_output_modes(query: str, conversation_history: dict):
chat_history=chat_history,
)
with timer("Chat actor: Infer output mode for chat response", logger):
response = await send_message_to_model_wrapper(relevant_mode_prompt)
try:
@ -230,6 +233,36 @@ async def aget_relevant_output_modes(query: str, conversation_history: dict):
return ConversationCommand.Default
async def infer_webpage_urls(q: str, conversation_history: dict, location_data: LocationData) -> List[str]:
"""
Infer webpage links from the given query
"""
location = f"{location_data.city}, {location_data.region}, {location_data.country}" if location_data else "Unknown"
chat_history = construct_chat_history(conversation_history)
utc_date = datetime.utcnow().strftime("%Y-%m-%d")
online_queries_prompt = prompts.infer_webpages_to_read.format(
current_date=utc_date,
query=q,
chat_history=chat_history,
location=location,
)
with timer("Chat actor: Infer webpage urls to read", logger):
response = await send_message_to_model_wrapper(online_queries_prompt, response_type="json_object")
# Validate that the response is a non-empty, JSON-serializable list of URLs
try:
response = response.strip()
urls = json.loads(response)
valid_unique_urls = {str(url).strip() for url in urls["links"] if is_valid_url(url)}
if is_none_or_empty(valid_unique_urls):
raise ValueError(f"Invalid list of urls: {response}")
return list(valid_unique_urls)
except Exception:
raise ValueError(f"Invalid list of urls: {response}")
async def generate_online_subqueries(q: str, conversation_history: dict, location_data: LocationData) -> List[str]:
"""
Generate subqueries from the given query
@ -245,6 +278,7 @@ async def generate_online_subqueries(q: str, conversation_history: dict, locatio
location=location,
)
with timer("Chat actor: Generate online search subqueries", logger):
response = await send_message_to_model_wrapper(online_queries_prompt, response_type="json_object")
# Validate that the response is a non-empty, JSON-serializable list
@ -274,6 +308,7 @@ async def extract_relevant_info(q: str, corpus: str) -> Union[str, None]:
corpus=corpus.strip(),
)
with timer("Chat actor: Extract relevant information from data", logger):
response = await send_message_to_model_wrapper(
extract_relevant_information, prompts.system_prompt_extract_relevant_information
)
@ -305,8 +340,8 @@ async def generate_better_image_prompt(
for result in online_results:
if online_results[result].get("answerBox"):
simplified_online_results[result] = online_results[result]["answerBox"]
elif online_results[result].get("extracted_content"):
simplified_online_results[result] = online_results[result]["extracted_content"]
elif online_results[result].get("webpages"):
simplified_online_results[result] = online_results[result]["webpages"]
image_prompt = prompts.image_generation_improve_prompt.format(
query=q,
@ -317,6 +352,7 @@ async def generate_better_image_prompt(
online_results=simplified_online_results,
)
with timer("Chat actor: Generate contextual image prompt", logger):
response = await send_message_to_model_wrapper(image_prompt)
return response.strip()
@ -367,7 +403,7 @@ def generate_chat_response(
meta_log: dict,
conversation: Conversation,
compiled_references: List[str] = [],
online_results: Dict[str, Any] = {},
online_results: Dict[str, Dict] = {},
inferred_queries: List[str] = [],
conversation_commands: List[ConversationCommand] = [ConversationCommand.Default],
user: KhojUser = None,

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):
if request.user.is_authenticated:
next_url = request.query_params.get("next", "/")
if request.user.is_authenticated:
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

@ -15,6 +15,7 @@ from os import path
from pathlib import Path
from time import perf_counter
from typing import TYPE_CHECKING, Optional, Union
from urllib.parse import urlparse
import torch
from asgiref.sync import sync_to_async
@ -270,6 +271,7 @@ class ConversationCommand(str, Enum):
Notes = "notes"
Help = "help"
Online = "online"
Webpage = "webpage"
Image = "image"
@ -278,15 +280,17 @@ command_descriptions = {
ConversationCommand.Notes: "Only talk about information that is available in your knowledge base.",
ConversationCommand.Default: "The default command when no command specified. It intelligently auto-switches between general and notes mode.",
ConversationCommand.Online: "Search for information on the internet.",
ConversationCommand.Webpage: "Get information from webpage links provided by you.",
ConversationCommand.Image: "Generate images by describing your imagination in words.",
ConversationCommand.Help: "Display a help message with all available commands and other metadata.",
}
tool_descriptions_for_llm = {
ConversationCommand.Default: "To use a mix of your internal knowledge and the user's personal knowledge, or if you don't entirely understand the query.",
ConversationCommand.General: "Use this when you can answer the question without any outside information or personal knowledge",
ConversationCommand.General: "To use when you can answer the question without any outside information or personal knowledge",
ConversationCommand.Notes: "To search the user's personal knowledge base. Especially helpful if the question expects context from the user's notes or documents.",
ConversationCommand.Online: "To search for the latest, up-to-date information from the internet. Note: **Questions about Khoj should always use this data source**",
ConversationCommand.Webpage: "To use if the user has directly provided the webpage urls or you are certain of the webpage urls to read.",
}
mode_descriptions_for_llm = {
@ -340,3 +344,12 @@ def in_debug_mode():
"""Check if Khoj is running in debug mode.
Set KHOJ_DEBUG environment variable to true to enable debug mode."""
return is_env_var_true("KHOJ_DEBUG")
def is_valid_url(url: str) -> bool:
"""Check if a string is a valid URL"""
try:
result = urlparse(url.strip())
return all([result.scheme, result.netloc])
except:
return False

View file

@ -579,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 = [
@ -590,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 = create_conversation(chat_log)
chat_history = create_conversation(chat_log, default_user2)
# Act
tools = await aget_relevant_information_sources(user_query, chat_history)

View file

@ -7,7 +7,10 @@ import pytest
from scipy.stats import linregress
from khoj.processor.embeddings import EmbeddingsModel
from khoj.processor.tools.online_search import read_webpage, read_webpage_with_olostep
from khoj.processor.tools.online_search import (
read_webpage_at_url,
read_webpage_with_olostep,
)
from khoj.utils import helpers
@ -90,7 +93,7 @@ async def test_reading_webpage():
website = "https://en.wikipedia.org/wiki/Great_Chicago_Fire"
# Act
response = await read_webpage(website)
response = await read_webpage_at_url(website)
# Assert
assert (

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

@ -11,6 +11,7 @@ from khoj.routers.helpers import (
aget_relevant_information_sources,
aget_relevant_output_modes,
generate_online_subqueries,
infer_webpage_urls,
)
from khoj.utils.helpers import ConversationCommand
@ -546,6 +547,34 @@ async def test_select_data_sources_actor_chooses_to_search_online(chat_client):
assert ConversationCommand.Online in conversation_commands
# ----------------------------------------------------------------------------------------------------
@pytest.mark.anyio
@pytest.mark.django_db(transaction=True)
async def test_select_data_sources_actor_chooses_to_read_webpage(chat_client):
# Arrange
user_query = "Summarize the wikipedia page on the history of the internet"
# Act
conversation_commands = await aget_relevant_information_sources(user_query, {})
# Assert
assert ConversationCommand.Webpage in conversation_commands
# ----------------------------------------------------------------------------------------------------
@pytest.mark.anyio
@pytest.mark.django_db(transaction=True)
async def test_infer_webpage_urls_actor_extracts_correct_links(chat_client):
# Arrange
user_query = "Summarize the wikipedia page on the history of the internet"
# Act
urls = await infer_webpage_urls(user_query, {}, None)
# Assert
assert "https://en.wikipedia.org/wiki/History_of_the_Internet" in urls
# Helpers
# ----------------------------------------------------------------------------------------------------
def populate_chat_history(message_list):