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

View File

@@ -196,6 +196,7 @@ export interface ChatHistoryData {
agent: AgentData; agent: AgentData;
conversation_id: string; conversation_id: string;
slug: string; slug: string;
is_owner: boolean;
} }
function sendFeedback(uquery: string, kquery: string, sentiment: string) { 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 { AppSidebar } from "@/app/components/appSidebar/appSidebar";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { KhojLogoType } from "@/app/components/logo/khojLogo"; import { KhojLogoType } from "@/app/components/logo/khojLogo";
import { Button } from "@/components/ui/button";
import { Trash } from "@phosphor-icons/react";
interface ChatBodyDataProps { interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null; chatOptionsData: ChatOptions | null;
@@ -34,6 +36,37 @@ interface ChatBodyDataProps {
conversationId?: string; conversationId?: string;
setQueryToProcess: (query: string) => void; setQueryToProcess: (query: string) => void;
setImages: (images: 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) { function ChatBodyData(props: ChatBodyDataProps) {
@@ -87,6 +120,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
conversationId={props.conversationId || ""} conversationId={props.conversationId || ""}
setAgent={setAgentMetadata} setAgent={setAgentMetadata}
setTitle={props.setTitle} setTitle={props.setTitle}
setIsOwner={props.setIsOwner}
pendingMessage={processingMessage ? message : ""} pendingMessage={processingMessage ? message : ""}
incomingMessages={props.streamedMessages} incomingMessages={props.streamedMessages}
customClassName={chatHistoryCustomClassName} customClassName={chatHistoryCustomClassName}
@@ -105,7 +139,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
agentColor={agentMetadata?.color} agentColor={agentMetadata?.color}
isMobileWidth={props.isMobileWidth} isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles} setUploadedFiles={props.setUploadedFiles}
setTriggeredAbort={() => { }} setTriggeredAbort={() => {}}
ref={chatInputRef} ref={chatInputRef}
/> />
</div> </div>
@@ -124,6 +158,7 @@ export default function SharedChat() {
const [uploadedFiles, setUploadedFiles] = useState<AttachedFileText[] | null>(null); const [uploadedFiles, setUploadedFiles] = useState<AttachedFileText[] | null>(null);
const [paramSlug, setParamSlug] = useState<string | undefined>(undefined); const [paramSlug, setParamSlug] = useState<string | undefined>(undefined);
const [images, setImages] = useState<string[]>([]); const [images, setImages] = useState<string[]>([]);
const [isOwner, setIsOwner] = useState(false);
const { const {
data: authenticatedData, data: authenticatedData,
@@ -215,6 +250,12 @@ export default function SharedChat() {
> >
{title} {title}
</h2> </h2>
{isOwner && authenticatedData && (
<UnshareButton
slug={paramSlug}
className={"h-4 w-4 mt-1"}
/>
)}
</> </>
) )
)} )}
@@ -237,6 +278,7 @@ export default function SharedChat() {
setUploadedFiles={setUploadedFiles} setUploadedFiles={setUploadedFiles}
isMobileWidth={isMobileWidth} isMobileWidth={isMobileWidth}
setImages={setImages} setImages={setImages}
setIsOwner={setIsOwner}
/> />
</Suspense> </Suspense>
</div> </div>

View File

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

View File

@@ -908,6 +908,10 @@ class PublicConversationAdapters:
# Public conversations are viewable by anyone, but not editable. # Public conversations are viewable by anyone, but not editable.
return f"/share/chat/{public_conversation.slug}/" 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: class ConversationAdapters:
@staticmethod @staticmethod

View File

@@ -11,7 +11,7 @@ from urllib.parse import unquote
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from fastapi import APIRouter, Depends, HTTPException, Request 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 starlette.authentication import has_required_scope, requires
from khoj.app.settings import ALLOWED_HOSTS from khoj.app.settings import ALLOWED_HOSTS
@@ -255,6 +255,7 @@ def chat_history(
"conversation_id": conversation.id, "conversation_id": conversation.id,
"slug": conversation.title if conversation.title else conversation.slug, "slug": conversation.title if conversation.title else conversation.slug,
"agent": agent_metadata, "agent": agent_metadata,
"is_owner": conversation.user == user,
} }
) )
@@ -332,6 +333,7 @@ def get_shared_chat(
"conversation_id": conversation.id, "conversation_id": conversation.id,
"slug": scrubbed_title, "slug": scrubbed_title,
"agent": agent_metadata, "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") @api_chat.get("/sessions")
@requires(["authenticated"]) @requires(["authenticated"])
def chat_sessions( def chat_sessions(