Make conversations optionally shareable (#712)

* Make conversations optionally shareable

- Shared conversations are viewable by anyone, without a login wall
- Can share a conversation from the three dot menu
- Add a new model for Public Conversation
- The rationale for a separate model is that public and private conversations have different assumptions. Separating them reduces some of the code specificity on our server-side code and allows us for easier interpretation and stricter security. Separating the data model makes it harder to accidentally view something that was meant to be private
- Add a new, read-only view for public conversations
This commit is contained in:
sabaimran 2024-05-05 10:46:04 -07:00 committed by GitHub
parent 88daa841fd
commit 14c9bea663
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2337 additions and 94 deletions

View file

@ -1075,11 +1075,12 @@
threeDotMenu.appendChild(conversationMenu);
let deleteButton = document.createElement('button');
deleteButton.type = "button";
deleteButton.innerHTML = "Delete";
deleteButton.classList.add("delete-conversation-button");
deleteButton.classList.add("three-dot-menu-button-item");
deleteButton.addEventListener('click', function() {
// Ask for confirmation before deleting chat session
deleteButton.addEventListener('click', function(event) {
event.preventDefault();
let confirmation = confirm('Are you sure you want to delete this chat session?');
if (!confirmation) return;
let deleteURL = `/api/chat/history?client=web&conversation_id=${incomingConversationId}`;
@ -1927,6 +1928,7 @@
text-align: left;
display: flex;
position: relative;
margin: 0 8px;
}
.three-dot-menu {

View file

@ -36,6 +36,7 @@ from khoj.database.models import (
NotionConfig,
OpenAIProcessorConversationConfig,
ProcessLock,
PublicConversation,
ReflectiveQuestion,
SearchModelConfig,
SpeechToTextModelOptions,
@ -560,7 +561,28 @@ class AgentAdapters:
return await Agent.objects.filter(name=AgentAdapters.DEFAULT_AGENT_NAME).afirst()
class PublicConversationAdapters:
@staticmethod
def get_public_conversation_by_slug(slug: str):
return PublicConversation.objects.filter(slug=slug).first()
@staticmethod
def get_public_conversation_url(public_conversation: PublicConversation):
# Public conversations are viewable by anyone, but not editable.
return f"/share/chat/{public_conversation.slug}/"
class ConversationAdapters:
@staticmethod
def make_public_conversation_copy(conversation: Conversation):
return PublicConversation.objects.create(
source_owner=conversation.user,
agent=conversation.agent,
conversation_log=conversation.conversation_log,
slug=conversation.slug,
title=conversation.title,
)
@staticmethod
def get_conversation_by_user(
user: KhojUser, client_application: ClientApplication = None, conversation_id: int = None
@ -680,6 +702,19 @@ class ConversationAdapters:
async def aget_default_conversation_config():
return await ChatModelOptions.objects.filter().prefetch_related("openai_config").afirst()
@staticmethod
def create_conversation_from_public_conversation(
user: KhojUser, public_conversation: PublicConversation, client_app: ClientApplication
):
return Conversation.objects.create(
user=user,
conversation_log=public_conversation.conversation_log,
client=client_app,
slug=public_conversation.slug,
title=public_conversation.title,
agent=public_conversation.agent,
)
@staticmethod
def save_conversation(
user: KhojUser,

View file

@ -0,0 +1,42 @@
# Generated by Django 4.2.10 on 2024-04-17 13:27
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0035_processlock"),
]
operations = [
migrations.CreateModel(
name="PublicConversation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("conversation_log", models.JSONField(default=dict)),
("slug", models.CharField(blank=True, default=None, max_length=200, null=True)),
("title", models.CharField(blank=True, default=None, max_length=200, null=True)),
(
"agent",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="database.agent",
),
),
(
"source_owner",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
],
options={
"abstract": False,
},
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 4.2.10 on 2024-05-04 10:10
from typing import List
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("database", "0036_publicconversation"),
("database", "0039_merge_20240501_0301"),
]
operations: List[str] = []

View file

@ -0,0 +1,14 @@
# Generated by Django 4.2.10 on 2024-05-05 12:34
from typing import List
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("database", "0040_alter_processlock_name"),
("database", "0040_merge_20240504_1010"),
]
operations: List[str] = []

View file

@ -1,3 +1,4 @@
import re
import uuid
from random import choice
@ -249,6 +250,36 @@ class Conversation(BaseModel):
agent = models.ForeignKey(Agent, on_delete=models.SET_NULL, default=None, null=True, blank=True)
class PublicConversation(BaseModel):
source_owner = models.ForeignKey(KhojUser, on_delete=models.CASCADE)
conversation_log = models.JSONField(default=dict)
slug = models.CharField(max_length=200, default=None, null=True, blank=True)
title = models.CharField(max_length=200, default=None, null=True, blank=True)
agent = models.ForeignKey(Agent, on_delete=models.SET_NULL, default=None, null=True, blank=True)
@receiver(pre_save, sender=PublicConversation)
def verify_public_conversation(sender, instance, **kwargs):
def generate_random_alphanumeric(length):
characters = "0123456789abcdefghijklmnopqrstuvwxyz"
return "".join(choice(characters) for _ in range(length))
# check if this is a new instance
if instance._state.adding:
slug = re.sub(r"\W+", "-", instance.slug.lower())[:50]
observed_random_id = set()
while PublicConversation.objects.filter(slug=slug).exists():
try:
random_id = generate_random_alphanumeric(7)
except IndexError:
raise ValidationError(
"Unable to generate a unique slug for the Public Conversation. Please try again later."
)
observed_random_id.add(random_id)
slug = f"{slug}-{random_id}"
instance.slug = slug
class ReflectiveQuestion(BaseModel):
question = models.CharField(max_length=500)
user = models.ForeignKey(KhojUser, on_delete=models.CASCADE, default=None, null=True, blank=True)

View file

@ -8,9 +8,11 @@ function toggleMenu() {
document.addEventListener('click', function(event) {
let menu = document.getElementById("khoj-nav-menu");
let menuContainer = document.getElementById("khoj-nav-menu-container");
let isClickOnMenu = menuContainer.contains(event.target) || menuContainer === event.target;
if (isClickOnMenu === false && menu.classList.contains("show")) {
menu.classList.remove("show");
if (menuContainer) {
let isClickOnMenu = menuContainer.contains(event.target) || menuContainer === event.target;
if (isClickOnMenu === false && menu.classList.contains("show")) {
menu.classList.remove("show");
}
}
});

View file

@ -1562,11 +1562,79 @@ To get started, just start typing below. You can also type / to see a list of co
conversationMenu.appendChild(editTitleButton);
threeDotMenu.appendChild(conversationMenu);
let shareButton = document.createElement('button');
shareButton.innerHTML = "Share";
shareButton.type = "button";
shareButton.classList.add("share-conversation-button");
shareButton.classList.add("three-dot-menu-button-item");
shareButton.addEventListener('click', function(event) {
event.preventDefault();
let confirmation = confirm('Are you sure you want to share this chat session? This will make the conversation public.');
if (!confirmation) return;
let duplicateURL = `/api/chat/share?client=web&conversation_id=${incomingConversationId}`;
fetch(duplicateURL , { method: "POST" })
.then(response => response.ok ? response.json() : Promise.reject(response))
.then(data => {
if (data.status == "ok") {
flashStatusInChatInput("✅ Conversation shared successfully");
}
// Make a pop-up that shows data.url to share the conversation
let shareURL = data.url;
let shareModal = document.createElement('div');
shareModal.classList.add("modal");
shareModal.id = "share-conversation-modal";
let shareModalContent = document.createElement('div');
shareModalContent.classList.add("modal-content");
let shareModalHeader = document.createElement('div');
shareModalHeader.classList.add("modal-header");
let shareModalTitle = document.createElement('h2');
shareModalTitle.textContent = "Share Conversation";
let shareModalCloseButton = document.createElement('button');
shareModalCloseButton.classList.add("modal-close-button");
shareModalCloseButton.innerHTML = "×";
shareModalCloseButton.addEventListener('click', function() {
shareModal.remove();
});
shareModalHeader.appendChild(shareModalTitle);
shareModalHeader.appendChild(shareModalCloseButton);
shareModalContent.appendChild(shareModalHeader);
let shareModalBody = document.createElement('div');
shareModalBody.classList.add("modal-body");
let shareModalText = document.createElement('p');
shareModalText.textContent = "The link has been copied to your clipboard. Use it to share your conversation with others!";
let shareModalLink = document.createElement('input');
shareModalLink.setAttribute("value", shareURL);
shareModalLink.setAttribute("readonly", "");
shareModalLink.classList.add("share-link");
let copyButton = document.createElement('button');
copyButton.textContent = "Copy";
copyButton.addEventListener('click', function() {
shareModalLink.select();
document.execCommand('copy');
});
copyButton.id = "copy-share-url-button";
shareModalBody.appendChild(shareModalText);
shareModalBody.appendChild(shareModalLink);
shareModalBody.appendChild(copyButton);
shareModalContent.appendChild(shareModalBody);
shareModal.appendChild(shareModalContent);
document.body.appendChild(shareModal);
shareModalLink.select();
document.execCommand('copy');
})
.catch(err => {
return;
});
});
conversationMenu.appendChild(shareButton);
let deleteButton = document.createElement('button');
deleteButton.type = "button";
deleteButton.innerHTML = "Delete";
deleteButton.classList.add("delete-conversation-button");
deleteButton.classList.add("three-dot-menu-button-item");
deleteButton.addEventListener('click', function() {
deleteButton.addEventListener('click', function(event) {
event.preventDefault();
// Ask for confirmation before deleting chat session
let confirmation = confirm('Are you sure you want to delete this chat session?');
if (!confirmation) return;
@ -2225,7 +2293,7 @@ To get started, just start typing below. You can also type / to see a list of co
}
.side-panel-button {
background: var(--background-color);
background: none;
border: none;
box-shadow: none;
font-size: 14px;
@ -2444,6 +2512,7 @@ To get started, just start typing below. You can also type / to see a list of co
margin-bottom: 8px;
}
button#copy-share-url-button,
button#new-conversation-button {
display: inline-flex;
align-items: center;
@ -2464,14 +2533,12 @@ To get started, just start typing below. You can also type / to see a list of co
text-align: left;
display: flex;
position: relative;
margin: 0 8px;
}
.three-dot-menu {
display: block;
/* background: var(--background-color); */
/* border: 1px solid var(--main-text-color); */
border-radius: 5px;
/* position: relative; */
position: absolute;
right: 4px;
top: 4px;
@ -2653,13 +2720,6 @@ To get started, just start typing below. You can also type / to see a list of co
color: #333;
}
#agent-instructions {
font-size: 14px;
color: #666;
height: 50px;
overflow: auto;
}
#agent-owned-by-user {
font-size: 12px;
color: #007BFF;
@ -2681,7 +2741,7 @@ To get started, just start typing below. You can also type / to see a list of co
margin: 15% auto; /* 15% from the top and centered */
padding: 20px;
border: 1px solid #888;
width: 250px;
width: 300px;
text-align: left;
background: var(--background-color);
border-radius: 5px;
@ -2755,6 +2815,28 @@ To get started, just start typing below. You can also type / to see a list of co
border: 1px solid var(--main-text-color);
}
.share-link {
display: block;
width: 100%;
padding: 10px;
margin-top: 10px;
border: 1px solid #ccc;
border-radius: 4px;
background-color: #f9f9f9;
font-family: 'Courier New', monospace;
color: #333;
font-size: 16px;
box-sizing: border-box;
transition: all 0.3s ease;
}
.share-link:focus {
outline: none;
border-color: #007BFF;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
button#copy-share-url-button,
button#new-conversation-submit-button {
background: var(--summer-sun);
transition: background 0.2s ease-in-out;
@ -2765,6 +2847,7 @@ To get started, just start typing below. You can also type / to see a list of co
transition: background 0.2s ease-in-out;
}
button#copy-share-url-button:hover,
button#new-conversation-submit-button:hover {
background: var(--primary);
}

View file

@ -16,7 +16,7 @@
<!-- Login Modal -->
<div id="login-modal">
<img class="khoj-logo" src="/static/assets/icons/favicon-128x128.png" alt="Khoj"></img>
<div class="login-modal-title">Log in to Khoj</div>
<div class="login-modal-title">Login to Khoj</div>
<!-- Sign Up/Login with Google OAuth -->
<div
class="g_id_signin"

View file

@ -1,71 +0,0 @@
{% extends "base_config.html" %}
{% block content %}
<div class="page">
<div class="section">
<h2 class="section-title">
<img class="card-icon" src="/static/assets/icons/chat.svg" alt="Chat">
<span class="card-title-text">Chat</span>
</h2>
<form id="config-form">
<table>
<tr>
<td>
<label for="openai-api-key" title="Get your OpenAI key from https://platform.openai.com/account/api-keys">OpenAI API key</label>
</td>
<td>
<input type="text" id="openai-api-key" name="openai-api-key" value="{{ current_config['api_key'] }}">
</td>
</tr>
<tr>
<td>
<label for="chat-model">Chat Model</label>
</td>
<td>
<input type="text" id="chat-model" name="chat-model" value="{{ current_config['chat_model'] }}">
</td>
</tr>
</table>
<div class="section">
<div id="success" style="display: none;" ></div>
<button id="submit" type="submit">Save</button>
</div>
</form>
</div>
</div>
<script>
submit.addEventListener("click", function(event) {
event.preventDefault();
var openai_api_key = document.getElementById("openai-api-key").value;
var chat_model = document.getElementById("chat-model").value;
if (openai_api_key == "" || chat_model == "") {
document.getElementById("success").innerHTML = "⚠️ Please fill all the fields.";
document.getElementById("success").style.display = "block";
return;
}
const csrfToken = document.cookie.split('; ').find(row => row.startsWith('csrftoken'))?.split('=')[1];
fetch('/api/config/data/processor/conversation/openai', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
"api_key": openai_api_key,
"chat_model": chat_model
})
})
.then(response => response.json())
.then(data => {
if (data["status"] == "ok") {
document.getElementById("success").innerHTML = "✅ Successfully updated. Go to your <a href='/config'>settings page</a> to complete setup.";
document.getElementById("success").style.display = "block";
} else {
document.getElementById("success").innerHTML = "⚠️ Failed to update settings.";
document.getElementById("success").style.display = "block";
}
})
});
</script>
{% endblock %}

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,12 @@ from starlette.authentication import requires
from starlette.websockets import WebSocketDisconnect
from websockets import ConnectionClosedOK
from khoj.database.adapters import ConversationAdapters, EntryAdapters, aget_user_name
from khoj.database.adapters import (
ConversationAdapters,
EntryAdapters,
PublicConversationAdapters,
aget_user_name,
)
from khoj.database.models import KhojUser
from khoj.processor.conversation.prompts import (
help_message,
@ -132,6 +137,60 @@ def chat_history(
return {"status": "ok", "response": meta_log}
@api_chat.get("/share/history")
def get_shared_chat(
request: Request,
common: CommonQueryParams,
public_conversation_slug: str,
n: Optional[int] = None,
):
user = request.user.object if request.user.is_authenticated else None
# Load Conversation History
conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug)
if conversation is None:
return Response(
content=json.dumps({"status": "error", "message": f"Conversation: {public_conversation_slug} not found"}),
status_code=404,
)
agent_metadata = None
if conversation.agent:
agent_metadata = {
"slug": conversation.agent.slug,
"name": conversation.agent.name,
"avatar": conversation.agent.avatar,
"isCreator": conversation.agent.creator == user,
}
meta_log = conversation.conversation_log
meta_log.update(
{
"conversation_id": conversation.id,
"slug": conversation.title if conversation.title else conversation.slug,
"agent": agent_metadata,
}
)
if n:
# Get latest N messages if N > 0
if n > 0 and meta_log.get("chat"):
meta_log["chat"] = meta_log["chat"][-n:]
# Else return all messages except latest N
elif n < 0 and meta_log.get("chat"):
meta_log["chat"] = meta_log["chat"][:n]
update_telemetry_state(
request=request,
telemetry_type="api",
api="public_conversation_history",
**common.__dict__,
)
return {"status": "ok", "response": meta_log}
@api_chat.delete("/history")
@requires(["authenticated"])
async def clear_chat_history(
@ -154,6 +213,66 @@ async def clear_chat_history(
return {"status": "ok", "message": "Conversation history cleared"}
@api_chat.post("/share/fork")
@requires(["authenticated"])
def fork_public_conversation(
request: Request,
common: CommonQueryParams,
public_conversation_slug: str,
):
user = request.user.object
# Load Conversation History
public_conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug)
# Duplicate Public Conversation to User's Private Conversation
ConversationAdapters.create_conversation_from_public_conversation(
user, public_conversation, request.user.client_app
)
chat_metadata = {"forked_conversation": public_conversation.slug}
update_telemetry_state(
request=request,
telemetry_type="api",
api="fork_public_conversation",
**common.__dict__,
metadata=chat_metadata,
)
redirect_uri = str(request.app.url_path_for("chat_page"))
return Response(status_code=200, content=json.dumps({"status": "ok", "next_url": redirect_uri}))
@api_chat.post("/share")
@requires(["authenticated"])
def duplicate_chat_history_public_conversation(
request: Request,
common: CommonQueryParams,
conversation_id: int,
):
user = request.user.object
# Duplicate Conversation History to Public Conversation
conversation = ConversationAdapters.get_conversation_by_user(user, request.user.client_app, conversation_id)
public_conversation = ConversationAdapters.make_public_conversation_copy(conversation)
public_conversation_url = PublicConversationAdapters.get_public_conversation_url(public_conversation)
update_telemetry_state(
request=request,
telemetry_type="api",
api="post_chat_share",
**common.__dict__,
)
return Response(
status_code=200, content=json.dumps({"status": "ok", "url": f"{request.client.host}{public_conversation_url}"})
)
@api_chat.get("/sessions")
@requires(["authenticated"])
def chat_sessions(

View file

@ -14,6 +14,7 @@ from khoj.database.adapters import (
AutomationAdapters,
ConversationAdapters,
EntryAdapters,
PublicConversationAdapters,
get_user_github_config,
get_user_name,
get_user_notion_config,
@ -350,9 +351,9 @@ def notion_config_page(request: Request):
@web_client.get("/config/content-source/computer", response_class=HTMLResponse)
@requires(["authenticated"], redirect="login_page")
def computer_config_page(request: Request):
user = request.user.object
user_picture = request.session.get("user", {}).get("picture")
has_documents = EntryAdapters.user_has_entries(user=user)
user = request.user.object if request.user.is_authenticated else None
user_picture = request.session.get("user", {}).get("picture") if user else None
has_documents = EntryAdapters.user_has_entries(user=user) if user else False
return templates.TemplateResponse(
"content_source_computer_input.html",
@ -367,6 +368,59 @@ def computer_config_page(request: Request):
)
@web_client.get("/share/chat/{public_conversation_slug}", response_class=HTMLResponse)
def view_public_conversation(request: Request):
public_conversation_slug = request.path_params.get("public_conversation_slug")
public_conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug)
if not public_conversation:
return templates.TemplateResponse(
"404.html",
context={
"request": request,
"khoj_version": state.khoj_version,
},
)
user = request.user.object if request.user.is_authenticated else None
user_picture = request.session.get("user", {}).get("picture") if user else None
has_documents = EntryAdapters.user_has_entries(user=user) if user else False
all_agents = AgentAdapters.get_all_accessible_agents(request.user.object if request.user.is_authenticated else None)
# Filter out the current agent
all_agents = [agent for agent in all_agents if agent != public_conversation.agent]
agents_packet = []
for agent in all_agents:
agents_packet.append(
{
"slug": agent.slug,
"avatar": agent.avatar,
"name": agent.name,
}
)
google_client_id = os.environ.get("GOOGLE_CLIENT_ID")
redirect_uri = str(request.app.url_path_for("auth"))
next_url = str(
request.app.url_path_for("view_public_conversation", public_conversation_slug=public_conversation_slug)
)
return templates.TemplateResponse(
"public_conversation.html",
context={
"request": request,
"username": user.username if user else None,
"user_photo": user_picture,
"is_active": has_required_scope(request, ["premium"]),
"has_documents": has_documents,
"khoj_version": state.khoj_version,
"public_conversation_slug": public_conversation_slug,
"agents": agents_packet,
"google_client_id": google_client_id,
"redirect_uri": f"{redirect_uri}?next={next_url}",
},
)
@web_client.get("/automations", response_class=HTMLResponse)
@requires(["authenticated"], redirect="login_page")
def automations_config_page(request: Request):