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 (
+
+ );
+}
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
);
}
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"""