From 28e566a1f44200dd09fa5d88b7ff3873a1e7c008 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:41:53 -0300 Subject: [PATCH] feat: add session delete functionality (#70) * implement delete user conversation on agent * format * implement delete session endpoint * implement delete session on persistence services * added deletion of sessions and added fetch sessions with query instead of with useEffect * removed unused texts * implemented dropdown menu on conversations --- frontend/components/delete-session-modal.tsx | 58 ++++ frontend/components/navigation-layout.tsx | 46 ++- frontend/components/navigation.tsx | 318 +++++++++++------- .../api/queries/useDeleteSessionMutation.ts | 57 ++++ .../api/queries/useGetConversationsQuery.ts | 105 ++++++ frontend/src/components/layout-wrapper.tsx | 30 +- src/agent.py | 34 +- src/api/chat.py | 24 ++ src/api/settings.py | 67 ++-- src/main.py | 12 + src/services/chat_service.py | 52 +++ .../conversation_persistence_service.py | 4 +- src/services/session_ownership_service.py | 14 + 13 files changed, 651 insertions(+), 170 deletions(-) create mode 100644 frontend/components/delete-session-modal.tsx create mode 100644 frontend/src/app/api/queries/useDeleteSessionMutation.ts create mode 100644 frontend/src/app/api/queries/useGetConversationsQuery.ts diff --git a/frontend/components/delete-session-modal.tsx b/frontend/components/delete-session-modal.tsx new file mode 100644 index 00000000..7b57a44f --- /dev/null +++ b/frontend/components/delete-session-modal.tsx @@ -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 ( + + + + + + Delete Conversation + + + Are you sure you want to delete "{sessionTitle}"? This + action cannot be undone and will permanently remove the conversation + and all its messages. + + + + + + + + + ); +} diff --git a/frontend/components/navigation-layout.tsx b/frontend/components/navigation-layout.tsx index fae8da62..d7a564a7 100644 --- a/frontend/components/navigation-layout.tsx +++ b/frontend/components/navigation-layout.tsx @@ -1,8 +1,12 @@ -"use client" +"use client"; -import { Navigation } from "@/components/navigation"; -import { ModeToggle } from "@/components/mode-toggle"; +import { usePathname } from "next/navigation"; +import { useGetConversationsQuery } from "@/app/api/queries/useGetConversationsQuery"; 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"; interface NavigationLayoutProps { @@ -11,11 +15,35 @@ interface NavigationLayoutProps { export function NavigationLayout({ children }: NavigationLayoutProps) { 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 (
- +
@@ -31,7 +59,7 @@ export function NavigationLayout({ children }: NavigationLayoutProps) { {/* Search component could go here */}
-
- {children} -
+
{children}
); -} \ No newline at end of file +} diff --git a/frontend/components/navigation.tsx b/frontend/components/navigation.tsx index b651ef6a..339b7d22 100644 --- a/frontend/components/navigation.tsx +++ b/frontend/components/navigation.tsx @@ -1,24 +1,35 @@ "use client"; -import { useChat } from "@/contexts/chat-context"; -import { cn } from "@/lib/utils"; import { + EllipsisVertical, FileText, Library, MessageSquare, + MoreHorizontal, Plus, Settings2, + Trash2, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { EndpointType } from "@/contexts/chat-context"; -import { useLoadingStore } from "@/stores/loadingStore"; -import { KnowledgeFilterList } from "./knowledge-filter-list"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useDeleteSessionMutation } from "@/app/api/queries/useDeleteSessionMutation"; +import { + 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 { 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; title: string; endpoint: string; @@ -35,7 +46,7 @@ interface RawConversation { [key: string]: unknown; } -interface ChatConversation { +export interface ChatConversation { response_id: string; title: string; endpoint: EndpointType; @@ -52,11 +63,20 @@ interface ChatConversation { [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 { endpoint, - refreshTrigger, loadConversation, currentConversationId, setCurrentConversationId, @@ -70,18 +90,64 @@ export function Navigation() { const { loading } = useLoadingStore(); - const [conversations, setConversations] = useState([]); - const [loadingConversations, setLoadingConversations] = useState(false); const [loadingNewConversation, setLoadingNewConversation] = useState(false); const [previousConversationCount, setPreviousConversationCount] = useState(0); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [conversationToDelete, setConversationToDelete] = + useState(null); const fileInputRef = useRef(null); const { selectedFilter, setSelectedFilter } = useKnowledgeFilter(); + // Delete session mutation + 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(); + startNewConversation(); + } + } + } + + setDeleteModalOpen(false); + setConversationToDelete(null); + }, + onError: (error) => { + toast.error(`Failed to delete conversation: ${error.message}`); + }, + }); + const handleNewConversation = () => { setLoadingNewConversation(true); - refreshConversations(); - startNewConversation(); + + // Use the prop callback if provided, otherwise use the context method + if (onNewConversation) { + onNewConversation(); + } else { + refreshConversations(); + startNewConversation(); + } + if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("newConversation")); } @@ -98,7 +164,7 @@ export function Navigation() { window.dispatchEvent( new CustomEvent("fileUploadStart", { detail: { filename: file.name }, - }) + }), ); try { @@ -122,7 +188,7 @@ export function Navigation() { filename: file.name, error: "Failed to process document", }, - }) + }), ); // Trigger loading end event @@ -142,7 +208,7 @@ export function Navigation() { window.dispatchEvent( new CustomEvent("fileUploaded", { detail: { file, result }, - }) + }), ); // Trigger loading end event @@ -156,7 +222,7 @@ export function Navigation() { window.dispatchEvent( new CustomEvent("fileUploadError", { 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 = [ { label: "Chat", @@ -200,91 +301,6 @@ export function Navigation() { const isOnChatPage = pathname === "/" || pathname === "/chat"; 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) useEffect(() => { 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", route.active ? "bg-accent text-accent-foreground shadow-sm" - : "text-foreground hover:text-accent-foreground" + : "text-foreground hover:text-accent-foreground", )} >
@@ -335,7 +351,7 @@ export function Navigation() { "h-4 w-4 mr-3 shrink-0", route.active ? "text-accent-foreground" - : "text-muted-foreground group-hover:text-foreground" + : "text-muted-foreground group-hover:text-foreground", )} /> {route.label} @@ -366,6 +382,7 @@ export function Navigation() { Conversations )} {/* Show regular conversations */} @@ -412,9 +430,10 @@ export function Navigation() {
) : ( conversations.map((conversation) => ( -
-
- {conversation.title} -
-
- {conversation.total_messages} messages -
- {conversation.last_activity && ( -
- {new Date( - conversation.last_activity - ).toLocaleDateString()} +
+
+
+ {conversation.title} +
- )} -
+ + + + + e.stopPropagation()} + > + { + e.stopPropagation(); + handleContextMenuAction( + "delete", + conversation, + ); + }} + className="cursor-pointer text-destructive focus:text-destructive" + > + + Delete conversation + + + +
+ )) )} @@ -456,6 +507,7 @@ export function Navigation() { Conversation knowledge
)} + + {/* Delete Session Modal */} + { + setDeleteModalOpen(false); + setConversationToDelete(null); + }} + onConfirm={confirmDeleteConversation} + sessionTitle={conversationToDelete?.title || ""} + isDeleting={deleteSessionMutation.isPending} + /> ); } diff --git a/frontend/src/app/api/queries/useDeleteSessionMutation.ts b/frontend/src/app/api/queries/useDeleteSessionMutation.ts new file mode 100644 index 00000000..996e8a44 --- /dev/null +++ b/frontend/src/app/api/queries/useDeleteSessionMutation.ts @@ -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, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + 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, + }); +}; diff --git a/frontend/src/app/api/queries/useGetConversationsQuery.ts b/frontend/src/app/api/queries/useGetConversationsQuery.ts new file mode 100644 index 00000000..f7e579b3 --- /dev/null +++ b/frontend/src/app/api/queries/useGetConversationsQuery.ts @@ -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, +) => { + const queryClient = useQueryClient(); + + async function getConversations(): Promise { + 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; +}; diff --git a/frontend/src/components/layout-wrapper.tsx b/frontend/src/components/layout-wrapper.tsx index 9be42730..79d4b095 100644 --- a/frontend/src/components/layout-wrapper.tsx +++ b/frontend/src/components/layout-wrapper.tsx @@ -2,28 +2,48 @@ import { Bell, Loader2 } from "lucide-react"; import { usePathname } from "next/navigation"; +import { useGetConversationsQuery } from "@/app/api/queries/useGetConversationsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel"; +import Logo from "@/components/logo/logo"; import { Navigation } from "@/components/navigation"; import { TaskNotificationMenu } from "@/components/task-notification-menu"; import { Button } from "@/components/ui/button"; import { UserNav } from "@/components/user-nav"; import { useAuth } from "@/contexts/auth-context"; +import { useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; // import { GitHubStarButton } from "@/components/github-star-button" // import { DiscordLink } from "@/components/discord-link" import { useTask } from "@/contexts/task-context"; -import Logo from "@/components/logo/logo"; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const { tasks, isMenuOpen, toggleMenu } = useTask(); const { isPanelOpen } = useKnowledgeFilter(); const { isLoading, isAuthenticated, isNoAuthMode } = useAuth(); + const { + endpoint, + refreshTrigger, + refreshConversations, + startNewConversation, + } = useChat(); const { isLoading: isSettingsLoading, data: settings } = useGetSettingsQuery({ 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 const authPaths = ["/login", "/auth/callback", "/onboarding"]; const isAuthPage = authPaths.includes(pathname); @@ -33,7 +53,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { (task) => task.status === "pending" || task.status === "running" || - task.status === "processing" + task.status === "processing", ); // Show loading state when backend isn't ready @@ -99,7 +119,11 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
- +
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 diff --git a/src/api/chat.py b/src/api/chat.py index b9dea5ef..58492118 100644 --- a/src/api/chat.py +++ b/src/api/chat.py @@ -155,3 +155,27 @@ async def langflow_history_endpoint(request: Request, chat_service, session_mana return JSONResponse( {"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 + ) diff --git a/src/api/settings.py b/src/api/settings.py index 37072c63..3e242c4b 100644 --- a/src/api/settings.py +++ b/src/api/settings.py @@ -47,9 +47,6 @@ def get_docling_preset_configs(): } - - - async def get_settings(request, session_manager): """Get application settings""" try: @@ -207,7 +204,9 @@ async def update_settings(request, session_manager): try: flows_service = _get_flows_service() 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: logger.error(f"Failed to update chat flow model: {str(e)}") # 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 try: 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") except Exception as 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 try: flows_service = _get_flows_service() - await flows_service.update_ingest_flow_embedding_model(body["embedding_model"].strip()) - logger.info(f"Successfully updated ingest flow embedding model to '{body['embedding_model'].strip()}'") + await flows_service.update_ingest_flow_embedding_model( + body["embedding_model"].strip() + ) + logger.info( + f"Successfully updated ingest flow embedding model to '{body['embedding_model'].strip()}'" + ) except Exception as e: logger.error(f"Failed to update ingest flow embedding model: {str(e)}") # 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 try: flows_service = _get_flows_service() - await flows_service.update_flow_docling_preset(body["doclingPresets"], preset_configs[body["doclingPresets"]]) - logger.info(f"Successfully updated docling preset in flow to '{body['doclingPresets']}'") + await flows_service.update_flow_docling_preset( + body["doclingPresets"], preset_configs[body["doclingPresets"]] + ) + logger.info( + f"Successfully updated docling preset in flow to '{body['doclingPresets']}'" + ) except Exception as e: logger.error(f"Failed to update docling preset in flow: {str(e)}") # Don't fail the entire settings update if flow update fails @@ -285,7 +294,9 @@ async def update_settings(request, session_manager): try: flows_service = _get_flows_service() 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: logger.error(f"Failed to update ingest flow chunk size: {str(e)}") # 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 try: flows_service = _get_flows_service() - await flows_service.update_ingest_flow_chunk_overlap(body["chunk_overlap"]) - logger.info(f"Successfully updated ingest flow chunk overlap to {body['chunk_overlap']}") + await flows_service.update_ingest_flow_chunk_overlap( + body["chunk_overlap"] + ) + logger.info( + f"Successfully updated ingest flow chunk overlap to {body['chunk_overlap']}" + ) except Exception as e: logger.error(f"Failed to update ingest flow chunk overlap: {str(e)}") # 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(): """Helper function to get flows service instance""" from services.flows_service import FlowsService + return FlowsService() @@ -605,8 +619,7 @@ async def update_docling_preset(request, session_manager): # Validate preset parameter if "preset" not in body: return JSONResponse( - {"error": "preset parameter is required"}, - status_code=400 + {"error": "preset parameter is required"}, status_code=400 ) preset = body["preset"] @@ -615,8 +628,10 @@ async def update_docling_preset(request, session_manager): if preset not in preset_configs: valid_presets = list(preset_configs.keys()) 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 @@ -628,16 +643,16 @@ async def update_docling_preset(request, session_manager): logger.info(f"Successfully updated docling preset to '{preset}' in ingest flow") - return JSONResponse({ - "message": f"Successfully updated docling preset to '{preset}'", - "preset": preset, - "preset_config": preset_config - }) + return JSONResponse( + { + "message": f"Successfully updated docling preset to '{preset}'", + "preset": preset, + "preset_config": preset_config, + } + ) except Exception as e: logger.error("Failed to update docling preset", error=str(e)) return JSONResponse( - {"error": f"Failed to update docling preset: {str(e)}"}, - status_code=500 + {"error": f"Failed to update docling preset: {str(e)}"}, status_code=500 ) - diff --git a/src/main.py b/src/main.py index 7df80b22..90add401 100644 --- a/src/main.py +++ b/src/main.py @@ -784,6 +784,18 @@ async def create_app(): ), 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 Route( "/auth/init", diff --git a/src/services/chat_service.py b/src/services/chat_service.py index 5ffe30f9..32536f4b 100644 --- a/src/services/chat_service.py +++ b/src/services/chat_service.py @@ -484,3 +484,55 @@ class ChatService: "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 + diff --git a/src/services/conversation_persistence_service.py b/src/services/conversation_persistence_service.py index fa5717c1..c6b62c24 100644 --- a/src/services/conversation_persistence_service.py +++ b/src/services/conversation_persistence_service.py @@ -86,12 +86,14 @@ class ConversationPersistenceService: user_conversations = self.get_user_conversations(user_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""" if user_id in self._conversations and response_id in self._conversations[user_id]: del self._conversations[user_id][response_id] self._save_conversations() logger.debug(f"Deleted conversation {response_id} for user {user_id}") + return True + return False def clear_user_conversations(self, user_id: str): """Clear all conversations for a user""" diff --git a/src/services/session_ownership_service.py b/src/services/session_ownership_service.py index 220a6d96..d700c5c3 100644 --- a/src/services/session_ownership_service.py +++ b/src/services/session_ownership_service.py @@ -74,6 +74,20 @@ class SessionOwnershipService: """Filter a list of sessions to only include those owned by the user""" user_sessions = self.get_user_sessions(user_id) 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]: """Get statistics about session ownership"""