Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
Lucas Oliveira
85b7aaa7e3 Merge remote-tracking branch 'origin/main' into feat/delete_session 2025-09-24 17:39:38 -03:00
Lucas Oliveira
357ab739bf implemented dropdown menu on conversations 2025-09-24 13:56:47 -03:00
Lucas Oliveira
4483ff82a8 removed unused texts 2025-09-24 13:37:21 -03:00
Lucas Oliveira
f41ffc8df2 added deletion of sessions and added fetch sessions with query instead of with useEffect 2025-09-24 13:11:47 -03:00
Lucas Oliveira
76c967ce17 implement delete session on persistence services 2025-09-24 13:11:18 -03:00
Lucas Oliveira
76f5540f19 implement delete session endpoint 2025-09-24 13:11:04 -03:00
Lucas Oliveira
36fbe26406 format 2025-09-24 13:10:51 -03:00
Lucas Oliveira
7ec608b5c5 implement delete user conversation on agent 2025-09-24 13:10:29 -03:00
13 changed files with 651 additions and 170 deletions

View file

@ -0,0 +1,58 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface DeleteSessionModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
sessionTitle: string;
isDeleting?: boolean;
}
export function DeleteSessionModal({
isOpen,
onClose,
onConfirm,
sessionTitle,
isDeleting = false,
}: DeleteSessionModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Delete Conversation
</DialogTitle>
<DialogDescription>
Are you sure you want to delete &quot;{sessionTitle}&quot;? This
action cannot be undone and will permanently remove the conversation
and all its messages.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,8 +1,12 @@
"use client" "use client";
import { Navigation } from "@/components/navigation"; import { usePathname } from "next/navigation";
import { ModeToggle } from "@/components/mode-toggle"; import { useGetConversationsQuery } from "@/app/api/queries/useGetConversationsQuery";
import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown"; import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown";
import { ModeToggle } from "@/components/mode-toggle";
import { Navigation } from "@/components/navigation";
import { useAuth } from "@/contexts/auth-context";
import { useChat } from "@/contexts/chat-context";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
interface NavigationLayoutProps { interface NavigationLayoutProps {
@ -11,11 +15,35 @@ interface NavigationLayoutProps {
export function NavigationLayout({ children }: NavigationLayoutProps) { export function NavigationLayout({ children }: NavigationLayoutProps) {
const { selectedFilter, setSelectedFilter } = useKnowledgeFilter(); const { selectedFilter, setSelectedFilter } = useKnowledgeFilter();
const pathname = usePathname();
const { isAuthenticated, isNoAuthMode } = useAuth();
const {
endpoint,
refreshTrigger,
refreshConversations,
startNewConversation,
} = useChat();
// Only fetch conversations on chat page
const isOnChatPage = pathname === "/" || pathname === "/chat";
const { data: conversations = [], isLoading: isConversationsLoading } =
useGetConversationsQuery(endpoint, refreshTrigger, {
enabled: isOnChatPage && (isAuthenticated || isNoAuthMode),
});
const handleNewConversation = () => {
refreshConversations();
startNewConversation();
};
return ( return (
<div className="h-full relative"> <div className="h-full relative">
<div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] border-r border-border/40"> <div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] border-r border-border/40">
<Navigation /> <Navigation
conversations={conversations}
isConversationsLoading={isConversationsLoading}
onNewConversation={handleNewConversation}
/>
</div> </div>
<main className="md:pl-72"> <main className="md:pl-72">
<div className="flex flex-col min-h-screen"> <div className="flex flex-col min-h-screen">
@ -41,9 +69,7 @@ export function NavigationLayout({ children }: NavigationLayoutProps) {
</div> </div>
</header> </header>
<div className="flex-1"> <div className="flex-1">
<div className="container py-6 lg:py-8"> <div className="container py-6 lg:py-8">{children}</div>
{children}
</div>
</div> </div>
</div> </div>
</main> </main>

View file

@ -1,24 +1,35 @@
"use client"; "use client";
import { useChat } from "@/contexts/chat-context";
import { cn } from "@/lib/utils";
import { import {
EllipsisVertical,
FileText, FileText,
Library, Library,
MessageSquare, MessageSquare,
MoreHorizontal,
Plus, Plus,
Settings2, Settings2,
Trash2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { EndpointType } from "@/contexts/chat-context"; import { useDeleteSessionMutation } from "@/app/api/queries/useDeleteSessionMutation";
import { useLoadingStore } from "@/stores/loadingStore"; import {
import { KnowledgeFilterList } from "./knowledge-filter-list"; DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { type EndpointType, useChat } from "@/contexts/chat-context";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { cn } from "@/lib/utils";
import { useLoadingStore } from "@/stores/loadingStore";
import { DeleteSessionModal } from "./delete-session-modal";
import { KnowledgeFilterList } from "./knowledge-filter-list";
interface RawConversation { // Re-export the types for backward compatibility
export interface RawConversation {
response_id: string; response_id: string;
title: string; title: string;
endpoint: string; endpoint: string;
@ -35,7 +46,7 @@ interface RawConversation {
[key: string]: unknown; [key: string]: unknown;
} }
interface ChatConversation { export interface ChatConversation {
response_id: string; response_id: string;
title: string; title: string;
endpoint: EndpointType; endpoint: EndpointType;
@ -52,11 +63,20 @@ interface ChatConversation {
[key: string]: unknown; [key: string]: unknown;
} }
export function Navigation() { interface NavigationProps {
conversations?: ChatConversation[];
isConversationsLoading?: boolean;
onNewConversation?: () => void;
}
export function Navigation({
conversations = [],
isConversationsLoading = false,
onNewConversation,
}: NavigationProps = {}) {
const pathname = usePathname(); const pathname = usePathname();
const { const {
endpoint, endpoint,
refreshTrigger,
loadConversation, loadConversation,
currentConversationId, currentConversationId,
setCurrentConversationId, setCurrentConversationId,
@ -70,18 +90,64 @@ export function Navigation() {
const { loading } = useLoadingStore(); const { loading } = useLoadingStore();
const [conversations, setConversations] = useState<ChatConversation[]>([]);
const [loadingConversations, setLoadingConversations] = useState(false);
const [loadingNewConversation, setLoadingNewConversation] = useState(false); const [loadingNewConversation, setLoadingNewConversation] = useState(false);
const [previousConversationCount, setPreviousConversationCount] = useState(0); const [previousConversationCount, setPreviousConversationCount] = useState(0);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [conversationToDelete, setConversationToDelete] =
useState<ChatConversation | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { selectedFilter, setSelectedFilter } = useKnowledgeFilter(); const { selectedFilter, setSelectedFilter } = useKnowledgeFilter();
const handleNewConversation = () => { // Delete session mutation
setLoadingNewConversation(true); const deleteSessionMutation = useDeleteSessionMutation({
onSuccess: () => {
toast.success("Conversation deleted successfully");
// If we deleted the current conversation, select another one
if (
conversationToDelete &&
currentConversationId === conversationToDelete.response_id
) {
// Filter out the deleted conversation and find the next one
const remainingConversations = conversations.filter(
(conv) => conv.response_id !== conversationToDelete.response_id,
);
if (remainingConversations.length > 0) {
// Load the first available conversation (most recent)
loadConversation(remainingConversations[0]);
} else {
// No conversations left, start a new one
setCurrentConversationId(null);
if (onNewConversation) {
onNewConversation();
} else {
refreshConversations(); refreshConversations();
startNewConversation(); startNewConversation();
}
}
}
setDeleteModalOpen(false);
setConversationToDelete(null);
},
onError: (error) => {
toast.error(`Failed to delete conversation: ${error.message}`);
},
});
const handleNewConversation = () => {
setLoadingNewConversation(true);
// Use the prop callback if provided, otherwise use the context method
if (onNewConversation) {
onNewConversation();
} else {
refreshConversations();
startNewConversation();
}
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("newConversation")); window.dispatchEvent(new CustomEvent("newConversation"));
} }
@ -98,7 +164,7 @@ export function Navigation() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("fileUploadStart", { new CustomEvent("fileUploadStart", {
detail: { filename: file.name }, detail: { filename: file.name },
}) }),
); );
try { try {
@ -122,7 +188,7 @@ export function Navigation() {
filename: file.name, filename: file.name,
error: "Failed to process document", error: "Failed to process document",
}, },
}) }),
); );
// Trigger loading end event // Trigger loading end event
@ -142,7 +208,7 @@ export function Navigation() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("fileUploaded", { new CustomEvent("fileUploaded", {
detail: { file, result }, detail: { file, result },
}) }),
); );
// Trigger loading end event // Trigger loading end event
@ -156,7 +222,7 @@ export function Navigation() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("fileUploadError", { new CustomEvent("fileUploadError", {
detail: { filename: file.name, error: "Failed to process document" }, detail: { filename: file.name, error: "Failed to process document" },
}) }),
); );
} }
}; };
@ -176,6 +242,41 @@ export function Navigation() {
} }
}; };
const handleDeleteConversation = (
conversation: ChatConversation,
event?: React.MouseEvent,
) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
setConversationToDelete(conversation);
setDeleteModalOpen(true);
};
const handleContextMenuAction = (
action: string,
conversation: ChatConversation,
) => {
switch (action) {
case "delete":
handleDeleteConversation(conversation);
break;
// Add more actions here in the future (rename, duplicate, etc.)
default:
break;
}
};
const confirmDeleteConversation = () => {
if (conversationToDelete) {
deleteSessionMutation.mutate({
sessionId: conversationToDelete.response_id,
endpoint: endpoint,
});
}
};
const routes = [ const routes = [
{ {
label: "Chat", label: "Chat",
@ -200,91 +301,6 @@ export function Navigation() {
const isOnChatPage = pathname === "/" || pathname === "/chat"; const isOnChatPage = pathname === "/" || pathname === "/chat";
const isOnKnowledgePage = pathname.startsWith("/knowledge"); const isOnKnowledgePage = pathname.startsWith("/knowledge");
const createDefaultPlaceholder = useCallback(() => {
return {
response_id: "new-conversation-" + Date.now(),
title: "New conversation",
endpoint: endpoint,
messages: [
{
role: "assistant",
content: "How can I assist?",
timestamp: new Date().toISOString(),
},
],
created_at: new Date().toISOString(),
last_activity: new Date().toISOString(),
total_messages: 1,
} as ChatConversation;
}, [endpoint]);
const fetchConversations = useCallback(async () => {
setLoadingConversations(true);
try {
// Fetch from the selected endpoint only
const apiEndpoint =
endpoint === "chat" ? "/api/chat/history" : "/api/langflow/history";
const response = await fetch(apiEndpoint);
if (response.ok) {
const history = await response.json();
const rawConversations = history.conversations || [];
// Cast conversations to proper type and ensure endpoint is correct
const conversations: ChatConversation[] = rawConversations.map(
(conv: RawConversation) => ({
...conv,
endpoint: conv.endpoint as EndpointType,
})
);
// Sort conversations by last activity (most recent first)
conversations.sort((a: ChatConversation, b: ChatConversation) => {
const aTime = new Date(
a.last_activity || a.created_at || 0
).getTime();
const bTime = new Date(
b.last_activity || b.created_at || 0
).getTime();
return bTime - aTime;
});
setConversations(conversations);
// If no conversations exist and no placeholder is shown, create a default placeholder
if (conversations.length === 0 && !placeholderConversation) {
setPlaceholderConversation(createDefaultPlaceholder());
}
} else {
setConversations([]);
// Also create placeholder when request fails and no conversations exist
if (!placeholderConversation) {
setPlaceholderConversation(createDefaultPlaceholder());
}
}
// Conversation documents are now managed in chat context
} catch (error) {
console.error(`Failed to fetch ${endpoint} conversations:`, error);
setConversations([]);
} finally {
setLoadingConversations(false);
}
}, [
endpoint,
placeholderConversation,
setPlaceholderConversation,
createDefaultPlaceholder,
]);
// Fetch chat conversations when on chat page, endpoint changes, or refresh is triggered
useEffect(() => {
if (isOnChatPage) {
fetchConversations();
}
}, [isOnChatPage, endpoint, refreshTrigger, fetchConversations]);
// Clear placeholder when conversation count increases (new conversation was created) // Clear placeholder when conversation count increases (new conversation was created)
useEffect(() => { useEffect(() => {
const currentCount = conversations.length; const currentCount = conversations.length;
@ -326,7 +342,7 @@ export function Navigation() {
"text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all", "text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all",
route.active route.active
? "bg-accent text-accent-foreground shadow-sm" ? "bg-accent text-accent-foreground shadow-sm"
: "text-foreground hover:text-accent-foreground" : "text-foreground hover:text-accent-foreground",
)} )}
> >
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
@ -335,7 +351,7 @@ export function Navigation() {
"h-4 w-4 mr-3 shrink-0", "h-4 w-4 mr-3 shrink-0",
route.active route.active
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground group-hover:text-foreground" : "text-muted-foreground group-hover:text-foreground",
)} )}
/> />
{route.label} {route.label}
@ -366,6 +382,7 @@ export function Navigation() {
Conversations Conversations
</h3> </h3>
<button <button
type="button"
className="p-1 hover:bg-accent rounded" className="p-1 hover:bg-accent rounded"
onClick={handleNewConversation} onClick={handleNewConversation}
title="Start new conversation" title="Start new conversation"
@ -379,7 +396,7 @@ export function Navigation() {
<div className="px-3 flex-1 min-h-0 flex flex-col"> <div className="px-3 flex-1 min-h-0 flex flex-col">
{/* Conversations List - grows naturally, doesn't fill all space */} {/* Conversations List - grows naturally, doesn't fill all space */}
<div className="flex-shrink-0 overflow-y-auto scrollbar-hide space-y-1 max-h-full"> <div className="flex-shrink-0 overflow-y-auto scrollbar-hide space-y-1 max-h-full">
{loadingNewConversation ? ( {loadingNewConversation || isConversationsLoading ? (
<div className="text-sm text-muted-foreground p-2"> <div className="text-sm text-muted-foreground p-2">
Loading... Loading...
</div> </div>
@ -387,8 +404,9 @@ export function Navigation() {
<> <>
{/* Show placeholder conversation if it exists */} {/* Show placeholder conversation if it exists */}
{placeholderConversation && ( {placeholderConversation && (
<div <button
className="p-2 rounded-lg bg-accent/50 border border-dashed border-accent cursor-pointer group" type="button"
className="w-full p-2 rounded-lg bg-accent/50 border border-dashed border-accent cursor-pointer group text-left"
onClick={() => { onClick={() => {
// Don't load placeholder as a real conversation, just focus the input // Don't load placeholder as a real conversation, just focus the input
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -402,7 +420,7 @@ export function Navigation() {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Start typing to begin... Start typing to begin...
</div> </div>
</div> </button>
)} )}
{/* Show regular conversations */} {/* Show regular conversations */}
@ -412,9 +430,10 @@ export function Navigation() {
</div> </div>
) : ( ) : (
conversations.map((conversation) => ( conversations.map((conversation) => (
<div <button
key={conversation.response_id} key={conversation.response_id}
className={`p-2 rounded-lg group ${ type="button"
className={`w-full px-3 pr-2 h-11 rounded-lg group relative text-left ${
loading loading
? "opacity-50 cursor-not-allowed" ? "opacity-50 cursor-not-allowed"
: "hover:bg-accent cursor-pointer" : "hover:bg-accent cursor-pointer"
@ -428,21 +447,53 @@ export function Navigation() {
loadConversation(conversation); loadConversation(conversation);
refreshConversations(); refreshConversations();
}} }}
disabled={loading}
> >
<div className="text-sm font-medium text-foreground mb-1 truncate"> <div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{conversation.title} {conversation.title}
</div> </div>
<div className="text-xs text-muted-foreground">
{conversation.total_messages} messages
</div> </div>
{conversation.last_activity && ( <DropdownMenu>
<div className="text-xs text-muted-foreground"> <DropdownMenuTrigger asChild>
{new Date( <button
conversation.last_activity type="button"
).toLocaleDateString()} className="opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-foreground transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground ml-2 flex-shrink-0"
</div> title="More options"
)} disabled={
loading || deleteSessionMutation.isPending
}
onClick={(e) => {
e.stopPropagation();
}}
>
<EllipsisVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className="w-48"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleContextMenuAction(
"delete",
conversation,
);
}}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</button>
)) ))
)} )}
</> </>
@ -456,6 +507,7 @@ export function Navigation() {
Conversation knowledge Conversation knowledge
</h3> </h3>
<button <button
type="button"
onClick={handleFilePickerClick} onClick={handleFilePickerClick}
className="p-1 hover:bg-accent rounded" className="p-1 hover:bg-accent rounded"
disabled={loading} disabled={loading}
@ -476,9 +528,9 @@ export function Navigation() {
No documents yet No documents yet
</div> </div>
) : ( ) : (
conversationDocs.map((doc, index) => ( conversationDocs.map((doc) => (
<div <div
key={index} key={`${doc.filename}-${doc.uploadTime.getTime()}`}
className="p-2 rounded-lg hover:bg-accent cursor-pointer group flex items-center" className="p-2 rounded-lg hover:bg-accent cursor-pointer group flex items-center"
> >
<FileText className="h-4 w-4 mr-2 text-muted-foreground flex-shrink-0" /> <FileText className="h-4 w-4 mr-2 text-muted-foreground flex-shrink-0" />
@ -495,6 +547,18 @@ export function Navigation() {
</div> </div>
</div> </div>
)} )}
{/* Delete Session Modal */}
<DeleteSessionModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setConversationToDelete(null);
}}
onConfirm={confirmDeleteConversation}
sessionTitle={conversationToDelete?.title || ""}
isDeleting={deleteSessionMutation.isPending}
/>
</div> </div>
); );
} }

View file

@ -0,0 +1,57 @@
import {
type MutationOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import type { EndpointType } from "@/contexts/chat-context";
interface DeleteSessionParams {
sessionId: string;
endpoint: EndpointType;
}
interface DeleteSessionResponse {
success: boolean;
message: string;
}
export const useDeleteSessionMutation = (
options?: Omit<
MutationOptions<DeleteSessionResponse, Error, DeleteSessionParams>,
"mutationFn"
>,
) => {
const queryClient = useQueryClient();
return useMutation<DeleteSessionResponse, Error, DeleteSessionParams>({
mutationFn: async ({ sessionId }: DeleteSessionParams) => {
const response = await fetch(`/api/sessions/${sessionId}`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.error || `Failed to delete session: ${response.status}`,
);
}
return response.json();
},
onSettled: (_data, _error, variables) => {
// Invalidate conversations query to refresh the list
// Use a slight delay to ensure the success callback completes first
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["conversations", variables.endpoint],
});
// Also invalidate any specific conversation queries
queryClient.invalidateQueries({
queryKey: ["conversations"],
});
}, 0);
},
...options,
});
};

View file

@ -0,0 +1,105 @@
import {
type UseQueryOptions,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { EndpointType } from "@/contexts/chat-context";
export interface RawConversation {
response_id: string;
title: string;
endpoint: string;
messages: Array<{
role: string;
content: string;
timestamp?: string;
response_id?: string;
}>;
created_at?: string;
last_activity?: string;
previous_response_id?: string;
total_messages: number;
[key: string]: unknown;
}
export interface ChatConversation {
response_id: string;
title: string;
endpoint: EndpointType;
messages: Array<{
role: string;
content: string;
timestamp?: string;
response_id?: string;
}>;
created_at?: string;
last_activity?: string;
previous_response_id?: string;
total_messages: number;
[key: string]: unknown;
}
export interface ConversationHistoryResponse {
conversations: RawConversation[];
[key: string]: unknown;
}
export const useGetConversationsQuery = (
endpoint: EndpointType,
refreshTrigger?: number,
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
) => {
const queryClient = useQueryClient();
async function getConversations(): Promise<ChatConversation[]> {
try {
// Fetch from the selected endpoint only
const apiEndpoint =
endpoint === "chat" ? "/api/chat/history" : "/api/langflow/history";
const response = await fetch(apiEndpoint);
if (!response.ok) {
console.error(`Failed to fetch conversations: ${response.status}`);
return [];
}
const history: ConversationHistoryResponse = await response.json();
const rawConversations = history.conversations || [];
// Cast conversations to proper type and ensure endpoint is correct
const conversations: ChatConversation[] = rawConversations.map(
(conv: RawConversation) => ({
...conv,
endpoint: conv.endpoint as EndpointType,
}),
);
// Sort conversations by last activity (most recent first)
conversations.sort((a: ChatConversation, b: ChatConversation) => {
const aTime = new Date(a.last_activity || a.created_at || 0).getTime();
const bTime = new Date(b.last_activity || b.created_at || 0).getTime();
return bTime - aTime;
});
return conversations;
} catch (error) {
console.error(`Failed to fetch ${endpoint} conversations:`, error);
return [];
}
}
const queryResult = useQuery(
{
queryKey: ["conversations", endpoint, refreshTrigger],
placeholderData: (prev) => prev,
queryFn: getConversations,
staleTime: 0, // Always consider data stale to ensure fresh data on trigger changes
gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
...options,
},
queryClient,
);
return queryResult;
};

View file

@ -2,28 +2,48 @@
import { Bell, Loader2 } from "lucide-react"; import { Bell, Loader2 } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useGetConversationsQuery } from "@/app/api/queries/useGetConversationsQuery";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel"; import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel";
import Logo from "@/components/logo/logo";
import { Navigation } from "@/components/navigation"; import { Navigation } from "@/components/navigation";
import { TaskNotificationMenu } from "@/components/task-notification-menu"; import { TaskNotificationMenu } from "@/components/task-notification-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { UserNav } from "@/components/user-nav"; import { UserNav } from "@/components/user-nav";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { useChat } from "@/contexts/chat-context";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
// import { GitHubStarButton } from "@/components/github-star-button" // import { GitHubStarButton } from "@/components/github-star-button"
// import { DiscordLink } from "@/components/discord-link" // import { DiscordLink } from "@/components/discord-link"
import { useTask } from "@/contexts/task-context"; import { useTask } from "@/contexts/task-context";
import Logo from "@/components/logo/logo";
export function LayoutWrapper({ children }: { children: React.ReactNode }) { export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { tasks, isMenuOpen, toggleMenu } = useTask(); const { tasks, isMenuOpen, toggleMenu } = useTask();
const { isPanelOpen } = useKnowledgeFilter(); const { isPanelOpen } = useKnowledgeFilter();
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth(); const { isLoading, isAuthenticated, isNoAuthMode } = useAuth();
const {
endpoint,
refreshTrigger,
refreshConversations,
startNewConversation,
} = useChat();
const { isLoading: isSettingsLoading, data: settings } = useGetSettingsQuery({ const { isLoading: isSettingsLoading, data: settings } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode, enabled: isAuthenticated || isNoAuthMode,
}); });
// Only fetch conversations on chat page
const isOnChatPage = pathname === "/" || pathname === "/chat";
const { data: conversations = [], isLoading: isConversationsLoading } =
useGetConversationsQuery(endpoint, refreshTrigger, {
enabled: isOnChatPage && (isAuthenticated || isNoAuthMode),
});
const handleNewConversation = () => {
refreshConversations();
startNewConversation();
};
// List of paths that should not show navigation // List of paths that should not show navigation
const authPaths = ["/login", "/auth/callback", "/onboarding"]; const authPaths = ["/login", "/auth/callback", "/onboarding"];
const isAuthPage = authPaths.includes(pathname); const isAuthPage = authPaths.includes(pathname);
@ -33,7 +53,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
(task) => (task) =>
task.status === "pending" || task.status === "pending" ||
task.status === "running" || task.status === "running" ||
task.status === "processing" task.status === "processing",
); );
// Show loading state when backend isn't ready // Show loading state when backend isn't ready
@ -99,7 +119,11 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
</div> </div>
</header> </header>
<div className="side-bar-arrangement bg-background fixed left-0 top-[53px] bottom-0 md:flex hidden"> <div className="side-bar-arrangement bg-background fixed left-0 top-[53px] bottom-0 md:flex hidden">
<Navigation /> <Navigation
conversations={conversations}
isConversationsLoading={isConversationsLoading}
onNewConversation={handleNewConversation}
/>
</div> </div>
<main <main
className={`md:pl-72 transition-all duration-300 overflow-y-auto h-[calc(100vh-53px)] ${ className={`md:pl-72 transition-all duration-300 overflow-y-auto h-[calc(100vh-53px)] ${

View file

@ -636,6 +636,34 @@ async def async_langflow_chat_stream(
logger.debug( logger.debug(
f"Stored langflow conversation thread for user {user_id} with response_id: {response_id}" f"Stored langflow conversation thread for user {user_id} with response_id: {response_id}"
) )
logger.debug(
f"Stored langflow conversation thread for user {user_id} with response_id: {response_id}"
) def delete_user_conversation(user_id: str, response_id: str) -> bool:
"""Delete a conversation for a user from both memory and persistent storage"""
deleted = False
try:
# Delete from in-memory storage
if user_id in active_conversations and response_id in active_conversations[user_id]:
del active_conversations[user_id][response_id]
logger.debug(f"Deleted conversation {response_id} from memory for user {user_id}")
deleted = True
# Delete from persistent storage
conversation_deleted = conversation_persistence.delete_conversation_thread(user_id, response_id)
if conversation_deleted:
logger.debug(f"Deleted conversation {response_id} from persistent storage for user {user_id}")
deleted = True
# Release session ownership
try:
from services.session_ownership_service import session_ownership_service
session_ownership_service.release_session(user_id, response_id)
logger.debug(f"Released session ownership for {response_id} for user {user_id}")
except Exception as e:
logger.warning(f"Failed to release session ownership: {e}")
return deleted
except Exception as e:
logger.error(f"Error deleting conversation {response_id} for user {user_id}: {e}")
return False

View file

@ -155,3 +155,27 @@ async def langflow_history_endpoint(request: Request, chat_service, session_mana
return JSONResponse( return JSONResponse(
{"error": f"Failed to get langflow history: {str(e)}"}, status_code=500 {"error": f"Failed to get langflow history: {str(e)}"}, status_code=500
) )
async def delete_session_endpoint(request: Request, chat_service, session_manager):
"""Delete a chat session"""
user = request.state.user
user_id = user.user_id
session_id = request.path_params["session_id"]
try:
# Delete from both local storage and Langflow
result = await chat_service.delete_session(user_id, session_id)
if result.get("success"):
return JSONResponse({"message": "Session deleted successfully"})
else:
return JSONResponse(
{"error": result.get("error", "Failed to delete session")},
status_code=500
)
except Exception as e:
logger.error(f"Error deleting session: {e}")
return JSONResponse(
{"error": f"Failed to delete session: {str(e)}"}, status_code=500
)

View file

@ -47,9 +47,6 @@ def get_docling_preset_configs():
} }
async def get_settings(request, session_manager): async def get_settings(request, session_manager):
"""Get application settings""" """Get application settings"""
try: try:
@ -207,7 +204,9 @@ async def update_settings(request, session_manager):
try: try:
flows_service = _get_flows_service() flows_service = _get_flows_service()
await flows_service.update_chat_flow_model(body["llm_model"]) await flows_service.update_chat_flow_model(body["llm_model"])
logger.info(f"Successfully updated chat flow model to '{body['llm_model']}'") logger.info(
f"Successfully updated chat flow model to '{body['llm_model']}'"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to update chat flow model: {str(e)}") logger.error(f"Failed to update chat flow model: {str(e)}")
# Don't fail the entire settings update if flow update fails # Don't fail the entire settings update if flow update fails
@ -220,7 +219,9 @@ async def update_settings(request, session_manager):
# Also update the chat flow with the new system prompt # Also update the chat flow with the new system prompt
try: try:
flows_service = _get_flows_service() flows_service = _get_flows_service()
await flows_service.update_chat_flow_system_prompt(body["system_prompt"]) await flows_service.update_chat_flow_system_prompt(
body["system_prompt"]
)
logger.info(f"Successfully updated chat flow system prompt") logger.info(f"Successfully updated chat flow system prompt")
except Exception as e: except Exception as e:
logger.error(f"Failed to update chat flow system prompt: {str(e)}") logger.error(f"Failed to update chat flow system prompt: {str(e)}")
@ -243,8 +244,12 @@ async def update_settings(request, session_manager):
# Also update the ingest flow with the new embedding model # Also update the ingest flow with the new embedding model
try: try:
flows_service = _get_flows_service() flows_service = _get_flows_service()
await flows_service.update_ingest_flow_embedding_model(body["embedding_model"].strip()) await flows_service.update_ingest_flow_embedding_model(
logger.info(f"Successfully updated ingest flow embedding model to '{body['embedding_model'].strip()}'") body["embedding_model"].strip()
)
logger.info(
f"Successfully updated ingest flow embedding model to '{body['embedding_model'].strip()}'"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to update ingest flow embedding model: {str(e)}") logger.error(f"Failed to update ingest flow embedding model: {str(e)}")
# Don't fail the entire settings update if flow update fails # Don't fail the entire settings update if flow update fails
@ -266,8 +271,12 @@ async def update_settings(request, session_manager):
# Also update the flow with the new docling preset # Also update the flow with the new docling preset
try: try:
flows_service = _get_flows_service() flows_service = _get_flows_service()
await flows_service.update_flow_docling_preset(body["doclingPresets"], preset_configs[body["doclingPresets"]]) await flows_service.update_flow_docling_preset(
logger.info(f"Successfully updated docling preset in flow to '{body['doclingPresets']}'") body["doclingPresets"], preset_configs[body["doclingPresets"]]
)
logger.info(
f"Successfully updated docling preset in flow to '{body['doclingPresets']}'"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to update docling preset in flow: {str(e)}") logger.error(f"Failed to update docling preset in flow: {str(e)}")
# Don't fail the entire settings update if flow update fails # Don't fail the entire settings update if flow update fails
@ -285,7 +294,9 @@ async def update_settings(request, session_manager):
try: try:
flows_service = _get_flows_service() flows_service = _get_flows_service()
await flows_service.update_ingest_flow_chunk_size(body["chunk_size"]) await flows_service.update_ingest_flow_chunk_size(body["chunk_size"])
logger.info(f"Successfully updated ingest flow chunk size to {body['chunk_size']}") logger.info(
f"Successfully updated ingest flow chunk size to {body['chunk_size']}"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to update ingest flow chunk size: {str(e)}") logger.error(f"Failed to update ingest flow chunk size: {str(e)}")
# Don't fail the entire settings update if flow update fails # Don't fail the entire settings update if flow update fails
@ -303,8 +314,12 @@ async def update_settings(request, session_manager):
# Also update the ingest flow with the new chunk overlap # Also update the ingest flow with the new chunk overlap
try: try:
flows_service = _get_flows_service() flows_service = _get_flows_service()
await flows_service.update_ingest_flow_chunk_overlap(body["chunk_overlap"]) await flows_service.update_ingest_flow_chunk_overlap(
logger.info(f"Successfully updated ingest flow chunk overlap to {body['chunk_overlap']}") body["chunk_overlap"]
)
logger.info(
f"Successfully updated ingest flow chunk overlap to {body['chunk_overlap']}"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to update ingest flow chunk overlap: {str(e)}") logger.error(f"Failed to update ingest flow chunk overlap: {str(e)}")
# Don't fail the entire settings update if flow update fails # Don't fail the entire settings update if flow update fails
@ -588,11 +603,10 @@ async def onboarding(request, flows_service):
) )
def _get_flows_service(): def _get_flows_service():
"""Helper function to get flows service instance""" """Helper function to get flows service instance"""
from services.flows_service import FlowsService from services.flows_service import FlowsService
return FlowsService() return FlowsService()
@ -605,8 +619,7 @@ async def update_docling_preset(request, session_manager):
# Validate preset parameter # Validate preset parameter
if "preset" not in body: if "preset" not in body:
return JSONResponse( return JSONResponse(
{"error": "preset parameter is required"}, {"error": "preset parameter is required"}, status_code=400
status_code=400
) )
preset = body["preset"] preset = body["preset"]
@ -615,8 +628,10 @@ async def update_docling_preset(request, session_manager):
if preset not in preset_configs: if preset not in preset_configs:
valid_presets = list(preset_configs.keys()) valid_presets = list(preset_configs.keys())
return JSONResponse( return JSONResponse(
{"error": f"Invalid preset '{preset}'. Valid presets: {', '.join(valid_presets)}"}, {
status_code=400 "error": f"Invalid preset '{preset}'. Valid presets: {', '.join(valid_presets)}"
},
status_code=400,
) )
# Get the preset configuration # Get the preset configuration
@ -628,16 +643,16 @@ async def update_docling_preset(request, session_manager):
logger.info(f"Successfully updated docling preset to '{preset}' in ingest flow") logger.info(f"Successfully updated docling preset to '{preset}' in ingest flow")
return JSONResponse({ return JSONResponse(
{
"message": f"Successfully updated docling preset to '{preset}'", "message": f"Successfully updated docling preset to '{preset}'",
"preset": preset, "preset": preset,
"preset_config": preset_config "preset_config": preset_config,
}) }
)
except Exception as e: except Exception as e:
logger.error("Failed to update docling preset", error=str(e)) logger.error("Failed to update docling preset", error=str(e))
return JSONResponse( return JSONResponse(
{"error": f"Failed to update docling preset: {str(e)}"}, {"error": f"Failed to update docling preset: {str(e)}"}, status_code=500
status_code=500
) )

View file

@ -784,6 +784,18 @@ async def create_app():
), ),
methods=["GET"], methods=["GET"],
), ),
# Session deletion endpoint
Route(
"/sessions/{session_id}",
require_auth(services["session_manager"])(
partial(
chat.delete_session_endpoint,
chat_service=services["chat_service"],
session_manager=services["session_manager"],
)
),
methods=["DELETE"],
),
# Authentication endpoints # Authentication endpoints
Route( Route(
"/auth/init", "/auth/init",

View file

@ -484,3 +484,55 @@ class ChatService:
"total_conversations": len(all_conversations), "total_conversations": len(all_conversations),
} }
async def delete_session(self, user_id: str, session_id: str):
"""Delete a session from both local storage and Langflow"""
try:
# Delete from local conversation storage
from agent import delete_user_conversation
local_deleted = delete_user_conversation(user_id, session_id)
# Delete from Langflow using the monitor API
langflow_deleted = await self._delete_langflow_session(session_id)
success = local_deleted or langflow_deleted
error_msg = None
if not success:
error_msg = "Session not found in local storage or Langflow"
return {
"success": success,
"local_deleted": local_deleted,
"langflow_deleted": langflow_deleted,
"error": error_msg
}
except Exception as e:
logger.error(f"Error deleting session {session_id} for user {user_id}: {e}")
return {
"success": False,
"error": str(e)
}
async def _delete_langflow_session(self, session_id: str):
"""Delete a session from Langflow using the monitor API"""
try:
response = await clients.langflow_request(
"DELETE",
f"/api/v1/monitor/messages/session/{session_id}"
)
if response.status_code == 200 or response.status_code == 204:
logger.info(f"Successfully deleted session {session_id} from Langflow")
return True
else:
logger.warning(
f"Failed to delete session {session_id} from Langflow: "
f"{response.status_code} - {response.text}"
)
return False
except Exception as e:
logger.error(f"Error deleting session {session_id} from Langflow: {e}")
return False

View file

@ -86,12 +86,14 @@ class ConversationPersistenceService:
user_conversations = self.get_user_conversations(user_id) user_conversations = self.get_user_conversations(user_id)
return user_conversations.get(response_id, {}) return user_conversations.get(response_id, {})
def delete_conversation_thread(self, user_id: str, response_id: str): def delete_conversation_thread(self, user_id: str, response_id: str) -> bool:
"""Delete a specific conversation thread""" """Delete a specific conversation thread"""
if user_id in self._conversations and response_id in self._conversations[user_id]: if user_id in self._conversations and response_id in self._conversations[user_id]:
del self._conversations[user_id][response_id] del self._conversations[user_id][response_id]
self._save_conversations() self._save_conversations()
logger.debug(f"Deleted conversation {response_id} for user {user_id}") logger.debug(f"Deleted conversation {response_id} for user {user_id}")
return True
return False
def clear_user_conversations(self, user_id: str): def clear_user_conversations(self, user_id: str):
"""Clear all conversations for a user""" """Clear all conversations for a user"""

View file

@ -75,6 +75,20 @@ class SessionOwnershipService:
user_sessions = self.get_user_sessions(user_id) user_sessions = self.get_user_sessions(user_id)
return [session for session in session_ids if session in user_sessions] return [session for session in session_ids if session in user_sessions]
def release_session(self, user_id: str, session_id: str) -> bool:
"""Release a session from a user (delete ownership record)"""
if session_id in self.ownership_data:
# Verify the user owns this session before deleting
if self.ownership_data[session_id].get("user_id") == user_id:
del self.ownership_data[session_id]
self._save_ownership_data()
logger.debug(f"Released session {session_id} from user {user_id}")
return True
else:
logger.warning(f"User {user_id} tried to release session {session_id} they don't own")
return False
return False
def get_ownership_stats(self) -> Dict[str, any]: def get_ownership_stats(self) -> Dict[str, any]:
"""Get statistics about session ownership""" """Get statistics about session ownership"""
users = set() users = set()