Compare commits
8 commits
main
...
feat/delet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b7aaa7e3 | ||
|
|
357ab739bf | ||
|
|
4483ff82a8 | ||
|
|
f41ffc8df2 | ||
|
|
76c967ce17 | ||
|
|
76f5540f19 | ||
|
|
36fbe26406 | ||
|
|
7ec608b5c5 |
13 changed files with 651 additions and 170 deletions
58
frontend/components/delete-session-modal.tsx
Normal file
58
frontend/components/delete-session-modal.tsx
Normal 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 "{sessionTitle}"? 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -31,7 +59,7 @@ export function NavigationLayout({ children }: NavigationLayoutProps) {
|
||||||
{/* Search component could go here */}
|
{/* Search component could go here */}
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center space-x-2">
|
<nav className="flex items-center space-x-2">
|
||||||
<KnowledgeFilterDropdown
|
<KnowledgeFilterDropdown
|
||||||
selectedFilter={selectedFilter}
|
selectedFilter={selectedFilter}
|
||||||
onFilterSelect={setSelectedFilter}
|
onFilterSelect={setSelectedFilter}
|
||||||
/>
|
/>
|
||||||
|
|
@ -41,12 +69,10 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
// 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 = () => {
|
const handleNewConversation = () => {
|
||||||
setLoadingNewConversation(true);
|
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") {
|
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">
|
||||||
{conversation.title}
|
<div className="flex-1 min-w-0">
|
||||||
</div>
|
<div className="text-sm font-medium text-foreground truncate">
|
||||||
<div className="text-xs text-muted-foreground">
|
{conversation.title}
|
||||||
{conversation.total_messages} messages
|
</div>
|
||||||
</div>
|
|
||||||
{conversation.last_activity && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{new Date(
|
|
||||||
conversation.last_activity
|
|
||||||
).toLocaleDateString()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<DropdownMenu>
|
||||||
</div>
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
frontend/src/app/api/queries/useDeleteSessionMutation.ts
Normal file
57
frontend/src/app/api/queries/useDeleteSessionMutation.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
105
frontend/src/app/api/queries/useGetConversationsQuery.ts
Normal file
105
frontend/src/app/api/queries/useGetConversationsQuery.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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)] ${
|
||||||
|
|
|
||||||
34
src/agent.py
34
src/agent.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}'",
|
{
|
||||||
"preset": preset,
|
"message": f"Successfully updated docling preset to '{preset}'",
|
||||||
"preset_config": preset_config
|
"preset": preset,
|
||||||
})
|
"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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
12
src/main.py
12
src/main.py
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,20 @@ class SessionOwnershipService:
|
||||||
"""Filter a list of sessions to only include those owned by the user"""
|
"""Filter a list of sessions to only include those owned by the user"""
|
||||||
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"""
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue