Enable unsharing of a public conversation (#1135)

This change enables the creator of a shared conversation to stop sharing the conversation publicly.

### Details
1. Create an API endpoint to enable the owner of the shared conversation to unshare it
2. Unshare a public conversations from the title pane of the public conversation on the web app
This commit is contained in:
Debanjum
2025-03-25 14:24:01 +05:30
committed by GitHub
6 changed files with 108 additions and 26 deletions

View File

@@ -42,6 +42,7 @@ interface ChatHistoryProps {
setAgent: (agent: AgentData) => void;
customClassName?: string;
setIsChatSideBarOpen?: (isOpen: boolean) => void;
setIsOwner?: (isOwner: boolean) => void;
}
interface TrainOfThoughtComponentProps {
@@ -60,13 +61,13 @@ function TrainOfThoughtComponent(props: TrainOfThoughtComponentProps) {
open: {
height: "auto",
opacity: 1,
transition: { duration: 0.3, ease: "easeOut" }
transition: { duration: 0.3, ease: "easeOut" },
},
closed: {
height: 0,
opacity: 0,
transition: { duration: 0.3, ease: "easeIn" }
}
transition: { duration: 0.3, ease: "easeIn" },
},
};
useEffect(() => {
@@ -103,17 +104,14 @@ function TrainOfThoughtComponent(props: TrainOfThoughtComponentProps) {
))}
<AnimatePresence initial={false}>
{!collapsed && (
<motion.div
initial="closed"
animate="open"
exit="closed"
variants={variants}
>
<motion.div initial="closed" animate="open" exit="closed" variants={variants}>
{props.trainOfThought.map((train, index) => (
<TrainOfThought
key={`train-${index}`}
message={train}
primary={index === lastIndex && props.lastMessage && !props.completed}
primary={
index === lastIndex && props.lastMessage && !props.completed
}
agentColor={props.agentColor}
/>
))}
@@ -174,7 +172,6 @@ export default function ChatHistory(props: ChatHistoryProps) {
latestUserMessageRef.current?.scrollIntoView({ behavior: "auto", block: "start" });
});
}
}, [data, currentPage]);
useEffect(() => {
@@ -247,6 +244,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
.then((response) => response.json())
.then((chatData: ChatResponse) => {
props.setTitle(chatData.response.slug);
props.setIsOwner && props.setIsOwner(chatData?.response?.is_owner);
if (
chatData &&
chatData.response &&
@@ -274,6 +272,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
agent: chatData.response.agent,
conversation_id: chatData.response.conversation_id,
slug: chatData.response.slug,
is_owner: chatData.response.is_owner,
};
props.setAgent(chatData.response.agent);
setData(chatMetadata);
@@ -312,7 +311,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
function constructAgentName() {
if (!data || !data.agent || !data.agent?.name) return `Agent`;
if (data.agent.is_hidden) return 'Khoj';
if (data.agent.is_hidden) return "Khoj";
return data.agent?.name;
}
@@ -359,8 +358,8 @@ export default function ChatHistory(props: ChatHistoryProps) {
md:h-[calc(100svh-theme(spacing.44))]
lg:h-[calc(100svh-theme(spacing.72))]
`}
ref={scrollAreaRef}>
ref={scrollAreaRef}
>
<div>
<div className={`${styles.chatHistory} ${props.customClassName}`}>
<div ref={sentinelRef} style={{ height: "1px" }}>
@@ -389,12 +388,12 @@ export default function ChatHistory(props: ChatHistoryProps) {
index === data.chat.length - 2
? latestUserMessageRef
: // attach ref to the newest fetched message to handle scroll on fetch
// note: stabilize index selection against last page having less messages than fetchMessageCount
index ===
// note: stabilize index selection against last page having less messages than fetchMessageCount
index ===
data.chat.length -
(currentPage - 1) * fetchMessageCount
? latestFetchedMessageRef
: null
(currentPage - 1) * fetchMessageCount
? latestFetchedMessageRef
: null
}
isMobileWidth={isMobileWidth}
chatMessage={chatMessage}
@@ -472,7 +471,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
onDeleteMessage={handleDeleteMessage}
customClassName="fullHistory"
borderLeftColor={`${data?.agent?.color}-500`}
isLastMessage={index === (props.incomingMessages!.length - 1)}
isLastMessage={index === props.incomingMessages!.length - 1}
/>
</React.Fragment>
);

View File

@@ -196,6 +196,7 @@ export interface ChatHistoryData {
agent: AgentData;
conversation_id: string;
slug: string;
is_owner: boolean;
}
function sendFeedback(uquery: string, kquery: string, sentiment: string) {

View File

@@ -22,6 +22,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
import { AppSidebar } from "@/app/components/appSidebar/appSidebar";
import { Separator } from "@/components/ui/separator";
import { KhojLogoType } from "@/app/components/logo/khojLogo";
import { Button } from "@/components/ui/button";
import { Trash } from "@phosphor-icons/react";
interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null;
@@ -34,6 +36,37 @@ interface ChatBodyDataProps {
conversationId?: string;
setQueryToProcess: (query: string) => void;
setImages: (images: string[]) => void;
setIsOwner: (isOwner: boolean) => void;
}
function UnshareButton({ slug, className }: { slug: string; className?: string }) {
const handleUnshare = async () => {
try {
const response = await fetch(`/api/chat/share?public_conversation_slug=${slug}`, {
method: "DELETE",
});
if (response.redirected) {
window.location.reload();
} else {
console.error("Failed to unshare conversation");
}
} catch (error) {
console.error("Error unsharing conversation:", error);
}
};
return (
<div className="flex items-center gap-2">
<Button
className="p-0 text-sm h-auto text-rose-500 hover:text-rose-600"
variant={"ghost"}
onClick={handleUnshare}
>
<Trash className={`${className}`} />
</Button>
</div>
);
}
function ChatBodyData(props: ChatBodyDataProps) {
@@ -87,6 +120,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
conversationId={props.conversationId || ""}
setAgent={setAgentMetadata}
setTitle={props.setTitle}
setIsOwner={props.setIsOwner}
pendingMessage={processingMessage ? message : ""}
incomingMessages={props.streamedMessages}
customClassName={chatHistoryCustomClassName}
@@ -105,7 +139,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
agentColor={agentMetadata?.color}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles}
setTriggeredAbort={() => { }}
setTriggeredAbort={() => {}}
ref={chatInputRef}
/>
</div>
@@ -124,6 +158,7 @@ export default function SharedChat() {
const [uploadedFiles, setUploadedFiles] = useState<AttachedFileText[] | null>(null);
const [paramSlug, setParamSlug] = useState<string | undefined>(undefined);
const [images, setImages] = useState<string[]>([]);
const [isOwner, setIsOwner] = useState(false);
const {
data: authenticatedData,
@@ -215,6 +250,12 @@ export default function SharedChat() {
>
{title}
</h2>
{isOwner && authenticatedData && (
<UnshareButton
slug={paramSlug}
className={"h-4 w-4 mt-1"}
/>
)}
</>
)
)}
@@ -237,6 +278,7 @@ export default function SharedChat() {
setUploadedFiles={setUploadedFiles}
isMobileWidth={isMobileWidth}
setImages={setImages}
setIsOwner={setIsOwner}
/>
</Suspense>
</div>

View File

@@ -93,10 +93,8 @@ div.agentIndicator {
padding: 10px;
}
@media (max-width: 768px) {
div.chatBody {
grid-template-columns: 0fr 1fr;
}
div.chatTitleWrapper {
grid-template-columns: 1fr auto;
}
@media screen and (max-width: 768px) {
@@ -108,6 +106,15 @@ div.agentIndicator {
width: 100%;
}
div.chatBody {
grid-template-columns: 0fr 1fr;
}
div.chatBox {
padding: 0;
height: 100%;
}
div.chatLayout {
gap: 0;
grid-template-columns: 1fr;

View File

@@ -908,6 +908,10 @@ class PublicConversationAdapters:
# Public conversations are viewable by anyone, but not editable.
return f"/share/chat/{public_conversation.slug}/"
@staticmethod
def delete_public_conversation_by_slug(user: KhojUser, slug: str):
return PublicConversation.objects.filter(source_owner=user, slug=slug).first().delete()
class ConversationAdapters:
@staticmethod

View File

@@ -11,7 +11,7 @@ from urllib.parse import unquote
from asgiref.sync import sync_to_async
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from fastapi.responses import RedirectResponse, Response, StreamingResponse
from starlette.authentication import has_required_scope, requires
from khoj.app.settings import ALLOWED_HOSTS
@@ -255,6 +255,7 @@ def chat_history(
"conversation_id": conversation.id,
"slug": conversation.title if conversation.title else conversation.slug,
"agent": agent_metadata,
"is_owner": conversation.user == user,
}
)
@@ -332,6 +333,7 @@ def get_shared_chat(
"conversation_id": conversation.id,
"slug": scrubbed_title,
"agent": agent_metadata,
"is_owner": conversation.source_owner == user,
}
)
@@ -449,6 +451,33 @@ def duplicate_chat_history_public_conversation(
)
@api_chat.delete("/share")
@requires(["authenticated"])
def delete_public_conversation(
request: Request,
common: CommonQueryParams,
public_conversation_slug: str,
):
user = request.user.object
# Delete Public Conversation
PublicConversationAdapters.delete_public_conversation_by_slug(user=user, slug=public_conversation_slug)
update_telemetry_state(
request=request,
telemetry_type="api",
api="delete_chat_share",
**common.__dict__,
)
# Redirect to the main chat page
redirect_uri = str(request.app.url_path_for("chat_page"))
return RedirectResponse(
url=redirect_uri,
status_code=301,
)
@api_chat.get("/sessions")
@requires(["authenticated"])
def chat_sessions(