📝 (frontend): Add new function 'refreshConversationsSilent' to update data without loading states

🚀 (frontend): Implement support for process.env.PORT to run app on a configurable port
🔧 (frontend): Change port variable case from lowercase 'port' to uppercase 'PORT' for better semantics
📝 (frontend): Add comments to clarify the purpose of loading conversation data only when user explicitly selects a conversation
📝 (frontend): Add comments to explain the logic for loading conversation data based on certain conditions
📝 (frontend): Add comments to describe the purpose of handling new conversation creation and resetting messages
📝 (frontend): Add comments to explain the logic for loading conversation data when conversationData changes
📝 (frontend): Add comments to clarify the purpose of loading conversations from the backend
📝 (frontend): Add comments to describe the logic for silent refresh to update data without loading states
📝 (frontend): Add comments to explain the purpose of starting a new conversation and creating a placeholder conversation
📝 (frontend): Add comments to clarify the logic for forking from a response and starting a new conversation
📝 (frontend): Add comments to describe the purpose of adding a conversation document and clearing conversation documents
📝 (frontend): Add comments to explain the logic for using a timeout to debounce multiple rapid refresh calls
📝 (frontend): Add comments to clarify the purpose of cleaning up timeout on unmount
📝 (frontend): Add comments to describe the logic for handling new conversation creation and resetting state
📝 (frontend): Add comments to explain the logic for forking from a response and starting a new conversation
📝 (frontend): Add comments to clarify the purpose of using useMemo for optimizing performance in ChatProvider
📝 (frontend): Add comments to describe the logic for using useMemo in the ChatProvider component
📝 (frontend): Add comments to explain the purpose of the useChat custom hook
📝 (frontend): Add comments to clarify the error message when useChat is not used within a ChatProvider
📝 (services): Update ChatService to fetch Langflow history with flow_id parameter for better control
This commit is contained in:
cristhianzl 2025-09-04 15:36:41 -03:00
parent 0db67b8c6a
commit 6dcb65debd
4 changed files with 495 additions and 180 deletions

View file

@ -91,8 +91,10 @@ function ChatPage() {
addConversationDoc,
forkFromResponse,
refreshConversations,
refreshConversationsSilent,
previousResponseIds,
setPreviousResponseIds,
placeholderConversation,
} = useChat();
const [messages, setMessages] = useState<Message[]>([
{
@ -133,6 +135,7 @@ function ChatPage() {
const dropdownRef = useRef<HTMLDivElement>(null);
const streamAbortRef = useRef<AbortController | null>(null);
const streamIdRef = useRef(0);
const lastLoadedConversationRef = useRef<string | null>(null);
const { addTask, isMenuOpen } = useTask();
const { selectedFilter, parsedFilterData, isPanelOpen, setSelectedFilter } =
useKnowledgeFilter();
@ -241,11 +244,16 @@ function ChatPage() {
...prev,
[endpoint]: result.response_id,
}));
// If this is a new conversation (no currentConversationId), set it now
if (!currentConversationId) {
setCurrentConversationId(result.response_id);
refreshConversations(true);
} else {
// For existing conversations, do a silent refresh to keep backend in sync
refreshConversationsSilent();
}
}
// Sidebar should show this conversation after upload creates it
try {
refreshConversations();
} catch {}
} else {
throw new Error(`Upload failed: ${response.status}`);
}
@ -406,6 +414,7 @@ function ChatPage() {
setExpandedFunctionCalls(new Set());
setIsFilterHighlighted(false);
setLoading(false);
lastLoadedConversationRef.current = null;
};
const handleFocusInput = () => {
@ -420,25 +429,19 @@ function ChatPage() {
};
}, []);
// Load conversation when conversationData changes
// Load conversation only when user explicitly selects a conversation
useEffect(() => {
const now = Date.now();
// Don't reset messages if user is in the middle of an interaction (like forking)
if (isUserInteracting || isForkingInProgress) {
console.log(
"Skipping conversation load due to user interaction or forking"
);
return;
}
// Don't reload if we just forked recently (within 1 second)
if (now - lastForkTimestamp < 1000) {
console.log("Skipping conversation load - recent fork detected");
return;
}
if (conversationData && conversationData.messages) {
// Only load conversation data when:
// 1. conversationData exists AND
// 2. It's different from the last loaded conversation AND
// 3. User is not in the middle of an interaction
if (
conversationData &&
conversationData.messages &&
lastLoadedConversationRef.current !== conversationData.response_id &&
!isUserInteracting &&
!isForkingInProgress
) {
console.log(
"Loading conversation with",
conversationData.messages.length,
@ -460,6 +463,7 @@ function ChatPage() {
);
setMessages(convertedMessages);
lastLoadedConversationRef.current = conversationData.response_id;
// Set the previous response ID for this conversation
setPreviousResponseIds((prev) => ({
@ -467,14 +471,16 @@ function ChatPage() {
[conversationData.endpoint]: conversationData.response_id,
}));
}
// Reset messages when starting a new conversation (but not during forking)
else if (
currentConversationId === null &&
!isUserInteracting &&
!isForkingInProgress &&
now - lastForkTimestamp > 1000
) {
console.log("Resetting to default message for new conversation");
}, [
conversationData,
isUserInteracting,
isForkingInProgress,
]);
// Handle new conversation creation - only reset messages when placeholderConversation is set
useEffect(() => {
if (placeholderConversation && currentConversationId === null) {
console.log("Starting new conversation");
setMessages([
{
role: "assistant",
@ -482,15 +488,9 @@ function ChatPage() {
timestamp: new Date(),
},
]);
lastLoadedConversationRef.current = null;
}
}, [
conversationData,
currentConversationId,
isUserInteracting,
isForkingInProgress,
lastForkTimestamp,
setPreviousResponseIds,
]);
}, [placeholderConversation, currentConversationId]);
// Listen for file upload events from navigation
useEffect(() => {
@ -1280,14 +1280,16 @@ function ChatPage() {
...prev,
[endpoint]: newResponseId,
}));
// If this is a new conversation (no currentConversationId), set it now
if (!currentConversationId) {
setCurrentConversationId(newResponseId);
refreshConversations(true);
} else {
// For existing conversations, do a silent refresh to keep backend in sync
refreshConversationsSilent();
}
}
// Trigger sidebar refresh to include this conversation (with small delay to ensure backend has processed)
setTimeout(() => {
try {
refreshConversations();
} catch {}
}, 100);
} catch (error) {
// If stream was aborted (e.g., starting new conversation), do not append errors or final messages
if (streamAbortRef.current?.signal.aborted) {
@ -1390,13 +1392,16 @@ function ChatPage() {
...prev,
[endpoint]: result.response_id,
}));
// If this is a new conversation (no currentConversationId), set it now
if (!currentConversationId) {
setCurrentConversationId(result.response_id);
refreshConversations(true);
} else {
// For existing conversations, do a silent refresh to keep backend in sync
refreshConversationsSilent();
}
}
// Trigger sidebar refresh to include/update this conversation (with small delay to ensure backend has processed)
setTimeout(() => {
try {
refreshConversations();
} catch {}
}, 100);
} else {
console.error("Chat failed:", result.error);
const errorMessage: Message = {
@ -2013,9 +2018,6 @@ function ChatPage() {
// Clear filter highlight when user starts typing
if (isFilterHighlighted) {
setIsFilterHighlighted(false);
try {
refreshConversations();
} catch {}
}
// Find if there's an @ at the start of the last word

View file

@ -0,0 +1,230 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Plus, MessageSquare, Database, Settings, GitBranch } from "lucide-react"
import { useChat } from "@/contexts/chat-context"
import { useAuth } from "@/contexts/auth-context"
interface Conversation {
id: string
title: string
endpoint: string
last_activity: string
created_at: string
response_id: string
messages?: Array<{
role: string
content: string
timestamp?: string
response_id?: string
}>
}
export function Navigation() {
const router = useRouter()
const pathname = usePathname()
const { user } = useAuth()
const {
refreshTrigger,
refreshTriggerSilent,
loadConversation,
startNewConversation,
currentConversationId,
placeholderConversation,
} = useChat()
const [conversations, setConversations] = useState<Conversation[]>([])
const [loading, setLoading] = useState(false)
// Load conversations from backend
const loadConversations = async () => {
if (!user) return
try {
setLoading(true)
const response = await fetch("/api/conversations")
if (response.ok) {
const data = await response.json()
setConversations(data.conversations || [])
}
} catch (error) {
console.error("Failed to load conversations:", error)
} finally {
setLoading(false)
}
}
// Load conversations on mount and when refreshTrigger changes (with loading state)
useEffect(() => {
loadConversations()
}, [refreshTrigger, user])
// Silent refresh - update data without loading state
useEffect(() => {
const loadSilent = async () => {
if (!user) return
try {
// Don't show loading state for silent refresh
const response = await fetch("/api/conversations")
if (response.ok) {
const data = await response.json()
setConversations(data.conversations || [])
}
} catch (error) {
console.error("Silent conversation refresh failed:", error)
}
}
// Only do silent refresh if we have a silent trigger change (not initial load)
if (refreshTriggerSilent > 0) {
loadSilent()
}
}, [refreshTriggerSilent, user])
const handleNewConversation = () => {
startNewConversation()
// Dispatch custom event to notify chat page
window.dispatchEvent(new CustomEvent('newConversation'))
router.push('/chat')
}
const handleConversationClick = async (conversation: Conversation) => {
try {
// Load full conversation data from backend
const response = await fetch(`/api/conversations/${conversation.response_id}`)
if (response.ok) {
const fullConversation = await response.json()
loadConversation(fullConversation)
router.push('/chat')
}
} catch (error) {
console.error("Failed to load conversation:", error)
}
}
const formatRelativeTime = (timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffHours / 24)
if (diffDays > 0) {
return `${diffDays}d ago`
} else if (diffHours > 0) {
return `${diffHours}h ago`
} else {
return 'Just now'
}
}
return (
<nav className="flex flex-col h-full w-72 bg-muted/30 border-r border-border">
{/* Header */}
<div className="p-4 border-b border-border">
<Button
onClick={handleNewConversation}
className="w-full justify-start gap-2"
variant="default"
>
<Plus className="h-4 w-4" />
New Conversation
</Button>
</div>
{/* Navigation Links */}
<div className="p-4 border-b border-border">
<div className="space-y-2">
<Button
variant={pathname === '/chat' ? 'secondary' : 'ghost'}
className="w-full justify-start gap-2"
onClick={() => router.push('/chat')}
>
<MessageSquare className="h-4 w-4" />
Chat
</Button>
<Button
variant={pathname === '/knowledge' ? 'secondary' : 'ghost'}
className="w-full justify-start gap-2"
onClick={() => router.push('/knowledge')}
>
<Database className="h-4 w-4" />
Knowledge
</Button>
<Button
variant={pathname === '/settings' ? 'secondary' : 'ghost'}
className="w-full justify-start gap-2"
onClick={() => router.push('/settings')}
>
<Settings className="h-4 w-4" />
Settings
</Button>
</div>
</div>
{/* Conversations List */}
<div className="flex-1 overflow-hidden">
<div className="px-4 py-2">
<h3 className="text-sm font-medium text-muted-foreground mb-2">Conversations</h3>
</div>
<div className="flex-1 overflow-y-auto px-2">
{loading ? (
<div className="p-4 text-sm text-muted-foreground">
Loading conversations...
</div>
) : (
<div className="space-y-1">
{/* Show placeholder conversation if exists */}
{placeholderConversation && (
<div className="p-2 rounded-md bg-primary/10 border border-primary/20">
<div className="flex items-center gap-2 text-sm">
<MessageSquare className="h-3 w-3 text-primary" />
<span className="text-primary font-medium">New conversation</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
Active
</div>
</div>
)}
{conversations.map((conversation) => (
<button
key={conversation.id}
onClick={() => handleConversationClick(conversation)}
className={`w-full text-left p-2 rounded-md transition-colors hover:bg-muted/50 ${
currentConversationId === conversation.response_id
? 'bg-muted border border-border'
: ''
}`}
>
<div className="flex items-center gap-2 mb-1">
<MessageSquare className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{conversation.title || 'Untitled'}
</span>
{conversation.endpoint === 'chat' && (
<GitBranch className="h-3 w-3 text-blue-400 ml-auto" />
)}
</div>
<div className="text-xs text-muted-foreground">
{formatRelativeTime(conversation.last_activity)}
</div>
</button>
))}
{conversations.length === 0 && !placeholderConversation && (
<div className="p-4 text-sm text-muted-foreground text-center">
No conversations yet
</div>
)}
</div>
)}
</div>
</div>
</nav>
)
}

View file

@ -1,161 +1,244 @@
"use client"
"use client";
import React, { createContext, useContext, useState, ReactNode } from 'react'
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export type EndpointType = 'chat' | 'langflow'
export type EndpointType = "chat" | "langflow";
interface ConversationDocument {
filename: string
uploadTime: Date
filename: string;
uploadTime: Date;
}
interface ConversationMessage {
role: string
content: string
timestamp?: string
response_id?: string
role: string;
content: string;
timestamp?: string;
response_id?: string;
}
interface ConversationData {
messages: ConversationMessage[]
endpoint: EndpointType
response_id: string
title: string
[key: string]: unknown
messages: ConversationMessage[];
endpoint: EndpointType;
response_id: string;
title: string;
[key: string]: unknown;
}
interface ChatContextType {
endpoint: EndpointType
setEndpoint: (endpoint: EndpointType) => void
currentConversationId: string | null
setCurrentConversationId: (id: string | null) => void
endpoint: EndpointType;
setEndpoint: (endpoint: EndpointType) => void;
currentConversationId: string | null;
setCurrentConversationId: (id: string | null) => void;
previousResponseIds: {
chat: string | null
langflow: string | null
}
setPreviousResponseIds: (ids: { chat: string | null; langflow: string | null } | ((prev: { chat: string | null; langflow: string | null }) => { chat: string | null; langflow: string | null })) => void
refreshConversations: () => void
refreshTrigger: number
loadConversation: (conversation: ConversationData) => void
startNewConversation: () => void
conversationData: ConversationData | null
forkFromResponse: (responseId: string) => void
conversationDocs: ConversationDocument[]
addConversationDoc: (filename: string) => void
clearConversationDocs: () => void
placeholderConversation: ConversationData | null
setPlaceholderConversation: (conversation: ConversationData | null) => void
chat: string | null;
langflow: string | null;
};
setPreviousResponseIds: (
ids:
| { chat: string | null; langflow: string | null }
| ((prev: { chat: string | null; langflow: string | null }) => {
chat: string | null;
langflow: string | null;
})
) => void;
refreshConversations: (force?: boolean) => void;
refreshConversationsSilent: () => Promise<void>;
refreshTrigger: number;
refreshTriggerSilent: number;
loadConversation: (conversation: ConversationData) => void;
startNewConversation: () => void;
conversationData: ConversationData | null;
forkFromResponse: (responseId: string) => void;
conversationDocs: ConversationDocument[];
addConversationDoc: (filename: string) => void;
clearConversationDocs: () => void;
placeholderConversation: ConversationData | null;
setPlaceholderConversation: (conversation: ConversationData | null) => void;
}
const ChatContext = createContext<ChatContextType | undefined>(undefined)
const ChatContext = createContext<ChatContextType | undefined>(undefined);
interface ChatProviderProps {
children: ReactNode
children: ReactNode;
}
export function ChatProvider({ children }: ChatProviderProps) {
const [endpoint, setEndpoint] = useState<EndpointType>('langflow')
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null)
const [endpoint, setEndpoint] = useState<EndpointType>("langflow");
const [currentConversationId, setCurrentConversationId] = useState<
string | null
>(null);
const [previousResponseIds, setPreviousResponseIds] = useState<{
chat: string | null
langflow: string | null
}>({ chat: null, langflow: null })
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [conversationData, setConversationData] = useState<ConversationData | null>(null)
const [conversationDocs, setConversationDocs] = useState<ConversationDocument[]>([])
const [placeholderConversation, setPlaceholderConversation] = useState<ConversationData | null>(null)
chat: string | null;
langflow: string | null;
}>({ chat: null, langflow: null });
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [refreshTriggerSilent, setRefreshTriggerSilent] = useState(0);
const [conversationData, setConversationData] =
useState<ConversationData | null>(null);
const [conversationDocs, setConversationDocs] = useState<
ConversationDocument[]
>([]);
const [placeholderConversation, setPlaceholderConversation] =
useState<ConversationData | null>(null);
const refreshConversations = () => {
setRefreshTrigger(prev => prev + 1)
}
// Debounce refresh requests to prevent excessive reloads
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const loadConversation = (conversation: ConversationData) => {
setCurrentConversationId(conversation.response_id)
setEndpoint(conversation.endpoint)
// Store the full conversation data for the chat page to use
// We'll pass it through a ref or state that the chat page can access
setConversationData(conversation)
// Clear placeholder when loading a real conversation
setPlaceholderConversation(null)
}
const startNewConversation = () => {
// Create a temporary placeholder conversation
const placeholderConversation: ConversationData = {
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()
const refreshConversations = useCallback((force = false) => {
if (force) {
// Immediate refresh for important updates like new conversations
setRefreshTrigger((prev) => prev + 1);
return;
}
setCurrentConversationId(null)
setPreviousResponseIds({ chat: null, langflow: null })
setConversationData(null)
setConversationDocs([])
setPlaceholderConversation(placeholderConversation)
// Force a refresh to ensure sidebar shows correct state
setRefreshTrigger(prev => prev + 1)
}
const addConversationDoc = (filename: string) => {
setConversationDocs(prev => [...prev, { filename, uploadTime: new Date() }])
}
// Clear any existing timeout
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
const clearConversationDocs = () => {
setConversationDocs([])
}
// Set a new timeout to debounce multiple rapid refresh calls
refreshTimeoutRef.current = setTimeout(() => {
setRefreshTrigger((prev) => prev + 1);
}, 250); // 250ms debounce
}, []);
const forkFromResponse = (responseId: string) => {
// Start a new conversation with the messages up to the fork point
setCurrentConversationId(null) // Clear current conversation to indicate new conversation
setConversationData(null) // Clear conversation data to prevent reloading
// Set the response ID that we're forking from as the previous response ID
setPreviousResponseIds(prev => ({
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, []);
// Silent refresh - updates data without loading states
const refreshConversationsSilent = useCallback(async () => {
// Trigger silent refresh that updates conversation data without showing loading states
setRefreshTriggerSilent((prev) => prev + 1);
}, []);
const loadConversation = useCallback((conversation: ConversationData) => {
setCurrentConversationId(conversation.response_id);
setEndpoint(conversation.endpoint);
// Store the full conversation data for the chat page to use
setConversationData(conversation);
// Clear placeholder when loading a real conversation
setPlaceholderConversation(null);
}, []);
const startNewConversation = useCallback(() => {
// Clear current conversation data and reset state
setCurrentConversationId(null);
setPreviousResponseIds({ chat: null, langflow: null });
setConversationData(null);
setConversationDocs([]);
// Create a temporary placeholder conversation to show in sidebar
const placeholderConversation: ConversationData = {
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(),
};
setPlaceholderConversation(placeholderConversation);
// Force immediate refresh to ensure sidebar shows correct state
refreshConversations(true);
}, [endpoint, refreshConversations]);
const addConversationDoc = useCallback((filename: string) => {
setConversationDocs((prev) => [
...prev,
[endpoint]: responseId
}))
// Clear placeholder when forking
setPlaceholderConversation(null)
// The messages are already set by the chat page component before calling this
}
{ filename, uploadTime: new Date() },
]);
}, []);
const value: ChatContextType = {
endpoint,
setEndpoint,
currentConversationId,
setCurrentConversationId,
previousResponseIds,
setPreviousResponseIds,
refreshConversations,
refreshTrigger,
loadConversation,
startNewConversation,
conversationData,
forkFromResponse,
conversationDocs,
addConversationDoc,
clearConversationDocs,
placeholderConversation,
setPlaceholderConversation,
}
const clearConversationDocs = useCallback(() => {
setConversationDocs([]);
}, []);
return (
<ChatContext.Provider value={value}>
{children}
</ChatContext.Provider>
)
const forkFromResponse = useCallback(
(responseId: string) => {
// Start a new conversation with the messages up to the fork point
setCurrentConversationId(null); // Clear current conversation to indicate new conversation
setConversationData(null); // Clear conversation data to prevent reloading
// Set the response ID that we're forking from as the previous response ID
setPreviousResponseIds((prev) => ({
...prev,
[endpoint]: responseId,
}));
// Clear placeholder when forking
setPlaceholderConversation(null);
// The messages are already set by the chat page component before calling this
},
[endpoint]
);
const value = useMemo<ChatContextType>(
() => ({
endpoint,
setEndpoint,
currentConversationId,
setCurrentConversationId,
previousResponseIds,
setPreviousResponseIds,
refreshConversations,
refreshConversationsSilent,
refreshTrigger,
refreshTriggerSilent,
loadConversation,
startNewConversation,
conversationData,
forkFromResponse,
conversationDocs,
addConversationDoc,
clearConversationDocs,
placeholderConversation,
setPlaceholderConversation,
}),
[
endpoint,
currentConversationId,
previousResponseIds,
refreshConversations,
refreshConversationsSilent,
refreshTrigger,
refreshTriggerSilent,
loadConversation,
startNewConversation,
conversationData,
forkFromResponse,
conversationDocs,
addConversationDoc,
clearConversationDocs,
placeholderConversation,
]
);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}
export function useChat(): ChatContextType {
const context = useContext(ChatContext)
const context = useContext(ChatContext);
if (context === undefined) {
throw new Error('useChat must be used within a ChatProvider')
throw new Error("useChat must be used within a ChatProvider");
}
return context
}
return context;
}

View file

@ -328,7 +328,7 @@ class ChatService:
# 2. Get historical conversations from Langflow database
# (works with both Google-bound users and direct Langflow users)
print(f"[DEBUG] Attempting to fetch Langflow history for user: {user_id}")
langflow_history = await langflow_history_service.get_user_conversation_history(user_id)
langflow_history = await langflow_history_service.get_user_conversation_history(user_id, flow_id=FLOW_ID)
if langflow_history.get("conversations"):
for conversation in langflow_history["conversations"]: