fix: new chat history glitches

This commit is contained in:
phact 2025-08-29 16:50:20 -04:00
parent 0fb9246e88
commit de10c05eef
9 changed files with 312 additions and 69 deletions

View file

@ -47,15 +47,21 @@ interface ChatConversation {
export function Navigation() {
const pathname = usePathname()
const { endpoint, refreshTrigger, loadConversation, currentConversationId, setCurrentConversationId, conversationDocs, addConversationDoc } = useChat()
const { endpoint, refreshTrigger, loadConversation, currentConversationId, setCurrentConversationId, startNewConversation, conversationDocs, addConversationDoc, refreshConversations, placeholderConversation, setPlaceholderConversation } = useChat()
const [conversations, setConversations] = useState<ChatConversation[]>([])
const [loadingConversations, setLoadingConversations] = useState(false)
const [previousConversationCount, setPreviousConversationCount] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleNewConversation = () => {
setCurrentConversationId(null)
// The chat page will handle resetting messages when it detects a new conversation request
window.dispatchEvent(new CustomEvent('newConversation'))
// Ensure current conversation appears in sidebar before starting a new one
refreshConversations()
// Use context helper to fully reset conversation state
startNewConversation()
// Notify chat view even if state was already 'new'
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('newConversation'))
}
}
const handleFileUpload = async (file: File) => {
@ -173,8 +179,44 @@ export function Navigation() {
})
setConversations(conversations)
// If no conversations exist and no placeholder is shown, create a default placeholder
if (conversations.length === 0 && !placeholderConversation) {
const defaultPlaceholder: ChatConversation = {
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
}
setPlaceholderConversation(defaultPlaceholder)
}
} else {
setConversations([])
// Also create placeholder when request fails and no conversations exist
if (!placeholderConversation) {
const defaultPlaceholder: ChatConversation = {
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
}
setPlaceholderConversation(defaultPlaceholder)
}
}
// Conversation documents are now managed in chat context
@ -194,6 +236,24 @@ export function Navigation() {
}
}, [isOnChatPage, endpoint, refreshTrigger, fetchConversations])
// Clear placeholder when conversation count increases (new conversation was created)
useEffect(() => {
const currentCount = conversations.length
// If we had a placeholder and the conversation count increased, clear the placeholder and highlight the new conversation
if (placeholderConversation && currentCount > previousConversationCount && conversations.length > 0) {
setPlaceholderConversation(null)
// Highlight the most recent conversation (first in sorted array) without loading its messages
const newestConversation = conversations[0]
if (newestConversation) {
setCurrentConversationId(newestConversation.response_id)
}
}
// Update the previous count
setPreviousConversationCount(currentCount)
}, [conversations.length, placeholderConversation, setPlaceholderConversation, previousConversationCount, conversations, setCurrentConversationId])
return (
<div className="space-y-4 py-4 flex flex-col h-full bg-background">
<div className="px-3 py-2 flex-shrink-0">
@ -244,32 +304,57 @@ export function Navigation() {
<div className="flex-shrink-0 overflow-y-auto scrollbar-hide space-y-1 max-h-full">
{loadingConversations ? (
<div className="text-sm text-muted-foreground p-2">Loading...</div>
) : conversations.length === 0 ? (
<div className="text-sm text-muted-foreground p-2">No conversations yet</div>
) : (
conversations.map((conversation) => (
<div
key={conversation.response_id}
className={`p-2 rounded-lg hover:bg-accent cursor-pointer group ${
currentConversationId === conversation.response_id ? 'bg-accent' : ''
}`}
onClick={() => {
loadConversation(conversation)
}}
>
<div className="text-sm font-medium text-foreground mb-1 truncate">
{conversation.title}
</div>
<div className="text-xs text-muted-foreground">
{conversation.total_messages} messages
</div>
{conversation.last_activity && (
<div className="text-xs text-muted-foreground">
{new Date(conversation.last_activity).toLocaleDateString()}
<>
{/* Show placeholder conversation if it exists */}
{placeholderConversation && (
<div
className="p-2 rounded-lg bg-accent/50 border border-dashed border-accent cursor-pointer group"
onClick={() => {
// Don't load placeholder as a real conversation, just focus the input
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('focusInput'))
}
}}
>
<div className="text-sm font-medium text-foreground mb-1 truncate">
{placeholderConversation.title}
</div>
)}
</div>
))
<div className="text-xs text-muted-foreground">
Start typing to begin...
</div>
</div>
)}
{/* Show regular conversations */}
{conversations.length === 0 && !placeholderConversation ? (
<div className="text-sm text-muted-foreground p-2">No conversations yet</div>
) : (
conversations.map((conversation) => (
<div
key={conversation.response_id}
className={`p-2 rounded-lg hover:bg-accent cursor-pointer group ${
currentConversationId === conversation.response_id ? 'bg-accent' : ''
}`}
onClick={() => {
loadConversation(conversation)
}}
>
<div className="text-sm font-medium text-foreground mb-1 truncate">
{conversation.title}
</div>
<div className="text-xs text-muted-foreground">
{conversation.total_messages} messages
</div>
{conversation.last_activity && (
<div className="text-xs text-muted-foreground">
{new Date(conversation.last_activity).toLocaleDateString()}
</div>
)}
</div>
))
)}
</>
)}
</div>
@ -316,4 +401,4 @@ export function Navigation() {
)}
</div>
)
}
}

View file

@ -70,7 +70,7 @@ interface RequestBody {
function ChatPage() {
const isDebugMode = process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_OPENRAG_DEBUG === 'true'
const { user } = useAuth()
const { endpoint, setEndpoint, currentConversationId, conversationData, setCurrentConversationId, addConversationDoc, forkFromResponse } = useChat()
const { endpoint, setEndpoint, currentConversationId, conversationData, setCurrentConversationId, addConversationDoc, forkFromResponse, refreshConversations, previousResponseIds, setPreviousResponseIds, setPlaceholderConversation } = useChat()
const [messages, setMessages] = useState<Message[]>([
{
role: "assistant",
@ -87,10 +87,7 @@ function ChatPage() {
timestamp: Date
} | null>(null)
const [expandedFunctionCalls, setExpandedFunctionCalls] = useState<Set<string>>(new Set())
const [previousResponseIds, setPreviousResponseIds] = useState<{
chat: string | null
langflow: string | null
}>({ chat: null, langflow: null })
// previousResponseIds now comes from useChat context
const [isUploading, setIsUploading] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false)
@ -107,6 +104,8 @@ function ChatPage() {
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const streamAbortRef = useRef<AbortController | null>(null)
const streamIdRef = useRef(0)
const { addTask, isMenuOpen } = useTask()
const { selectedFilter, parsedFilterData, isPanelOpen, setSelectedFilter } = useKnowledgeFilter()
@ -209,6 +208,8 @@ function ChatPage() {
[endpoint]: result.response_id
}))
}
// Sidebar should show this conversation after upload creates it
try { refreshConversations() } catch {}
} else {
throw new Error(`Upload failed: ${response.status}`)
@ -351,6 +352,40 @@ function ChatPage() {
inputRef.current?.focus()
}, [])
// Explicitly handle external new conversation trigger
useEffect(() => {
const handleNewConversation = () => {
// Abort any in-flight streaming so it doesn't bleed into new chat
if (streamAbortRef.current) {
streamAbortRef.current.abort()
}
// Reset chat UI even if context state was already 'new'
setMessages([
{
role: "assistant",
content: "How can I assist?",
timestamp: new Date(),
},
])
setInput("")
setStreamingMessage(null)
setExpandedFunctionCalls(new Set())
setIsFilterHighlighted(false)
setLoading(false)
}
const handleFocusInput = () => {
inputRef.current?.focus()
}
window.addEventListener('newConversation', handleNewConversation)
window.addEventListener('focusInput', handleFocusInput)
return () => {
window.removeEventListener('newConversation', handleNewConversation)
window.removeEventListener('focusInput', handleFocusInput)
}
}, [])
// Load conversation when conversationData changes
useEffect(() => {
const now = Date.now()
@ -499,6 +534,13 @@ function ChatPage() {
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"
try {
// Abort any existing stream before starting a new one
if (streamAbortRef.current) {
streamAbortRef.current.abort()
}
const controller = new AbortController()
streamAbortRef.current = controller
const thisStreamId = ++streamIdRef.current
const requestBody: RequestBody = {
prompt: userMessage.content,
stream: true,
@ -536,6 +578,7 @@ function ChatPage() {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: controller.signal,
})
if (!response.ok) {
@ -554,18 +597,19 @@ function ChatPage() {
let newResponseId: string | null = null
// Initialize streaming message
setStreamingMessage({
content: "",
functionCalls: [],
timestamp: new Date()
})
if (!controller.signal.aborted && thisStreamId === streamIdRef.current) {
setStreamingMessage({
content: "",
functionCalls: [],
timestamp: new Date()
})
}
try {
while (true) {
const { done, value } = await reader.read()
if (controller.signal.aborted || thisStreamId !== streamIdRef.current) break
if (done) break
buffer += decoder.decode(value, { stream: true })
// Process complete lines (JSON objects)
@ -908,11 +952,13 @@ function ChatPage() {
}
// Update streaming message
setStreamingMessage({
content: currentContent,
functionCalls: [...currentFunctionCalls],
timestamp: new Date()
})
if (!controller.signal.aborted && thisStreamId === streamIdRef.current) {
setStreamingMessage({
content: currentContent,
functionCalls: [...currentFunctionCalls],
timestamp: new Date()
})
}
} catch (parseError) {
console.warn("Failed to parse chunk:", line, parseError)
@ -932,18 +978,29 @@ function ChatPage() {
timestamp: new Date()
}
setMessages(prev => [...prev, finalMessage])
setStreamingMessage(null)
if (!controller.signal.aborted && thisStreamId === streamIdRef.current) {
setMessages(prev => [...prev, finalMessage])
setStreamingMessage(null)
}
// Store the response ID for the next request for this endpoint
if (newResponseId) {
if (newResponseId && !controller.signal.aborted && thisStreamId === streamIdRef.current) {
setPreviousResponseIds(prev => ({
...prev,
[endpoint]: newResponseId
}))
}
// 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) {
return
}
console.error("SSE Stream error:", error)
setStreamingMessage(null)
@ -1034,6 +1091,10 @@ function ChatPage() {
[endpoint]: result.response_id
}))
}
// 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 = {
@ -1583,8 +1644,9 @@ function ChatPage() {
// Clear filter highlight when user starts typing
if (isFilterHighlighted) {
setIsFilterHighlighted(false)
}
setIsFilterHighlighted(false)
try { refreshConversations() } catch {}
}
// Find if there's an @ at the start of the last word
const words = newValue.split(' ')

View file

@ -8,17 +8,17 @@ import { useAuth } from "@/contexts/auth-context"
import { Lock, LogIn, Loader2 } from "lucide-react"
function LoginPageContent() {
const { isLoading, isAuthenticated, login } = useAuth()
const { isLoading, isAuthenticated, isNoAuthMode, login } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/chat'
// Redirect if already authenticated
// Redirect if already authenticated or in no-auth mode
useEffect(() => {
if (!isLoading && isAuthenticated) {
if (!isLoading && (isAuthenticated || isNoAuthMode)) {
router.push(redirect)
}
}, [isLoading, isAuthenticated, router, redirect])
}, [isLoading, isAuthenticated, isNoAuthMode, router, redirect])
if (isLoading) {
return (
@ -31,7 +31,7 @@ function LoginPageContent() {
)
}
if (isAuthenticated) {
if (isAuthenticated || isNoAuthMode) {
return null // Will redirect in useEffect
}

View file

@ -12,11 +12,14 @@ import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel"
// import { DiscordLink } from "@/components/discord-link"
import { useTask } from "@/contexts/task-context"
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
import { useAuth } from "@/contexts/auth-context"
import { Loader2 } from "lucide-react"
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const { tasks, isMenuOpen, toggleMenu } = useTask()
const { selectedFilter, setSelectedFilter, isPanelOpen } = useKnowledgeFilter()
const { isLoading } = useAuth()
// List of paths that should not show navigation
const authPaths = ['/login', '/auth/callback']
@ -27,6 +30,18 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
task.status === 'pending' || task.status === 'running' || task.status === 'processing'
)
// Show loading state when backend isn't ready
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-muted-foreground">Starting OpenRAG...</p>
</div>
</div>
)
}
if (isAuthPage) {
// For auth pages, render without navigation
return (

View file

@ -10,19 +10,24 @@ interface ProtectedRouteProps {
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isLoading, isAuthenticated } = useAuth()
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth()
const router = useRouter()
const pathname = usePathname()
console.log("ProtectedRoute - isLoading:", isLoading, "isAuthenticated:", isAuthenticated, "pathname:", pathname)
console.log("ProtectedRoute - isLoading:", isLoading, "isAuthenticated:", isAuthenticated, "isNoAuthMode:", isNoAuthMode, "pathname:", pathname)
useEffect(() => {
// In no-auth mode, allow access without authentication
if (isNoAuthMode) {
return
}
if (!isLoading && !isAuthenticated) {
// Redirect to login with current path as redirect parameter
const redirectUrl = `/login?redirect=${encodeURIComponent(pathname)}`
router.push(redirectUrl)
}
}, [isLoading, isAuthenticated, router, pathname])
}, [isLoading, isAuthenticated, isNoAuthMode, router, pathname])
// Show loading state while checking authentication
if (isLoading) {
@ -36,6 +41,11 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
)
}
// In no-auth mode, always render content
if (isNoAuthMode) {
return <>{children}</>
}
// Don't render anything if not authenticated (will redirect)
if (!isAuthenticated) {
return null

View file

@ -15,7 +15,7 @@ import { LogIn, LogOut, User, Moon, Sun, ChevronsUpDown } from "lucide-react"
import { useTheme } from "next-themes"
export function UserNav() {
const { user, isLoading, isAuthenticated, login, logout } = useAuth()
const { user, isLoading, isAuthenticated, isNoAuthMode, login, logout } = useAuth()
const { theme, setTheme } = useTheme()
if (isLoading) {
@ -24,6 +24,20 @@ export function UserNav() {
)
}
// In no-auth mode, show a simple theme switcher instead of auth UI
if (isNoAuthMode) {
return (
<Button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
)
}
if (!isAuthenticated) {
return (
<Button

View file

@ -15,6 +15,7 @@ interface AuthContextType {
user: User | null
isLoading: boolean
isAuthenticated: boolean
isNoAuthMode: boolean
login: () => void
logout: () => Promise<void>
refreshAuth: () => Promise<void>
@ -37,26 +38,49 @@ interface AuthProviderProps {
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isNoAuthMode, setIsNoAuthMode] = useState(false)
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/me')
// If we can't reach the backend, keep loading
if (!response.ok && (response.status === 0 || response.status >= 500)) {
console.log('Backend not ready, retrying in 2 seconds...')
setTimeout(checkAuth, 2000)
return
}
const data = await response.json()
if (data.authenticated && data.user) {
// Check if we're in no-auth mode
if (data.no_auth_mode) {
setIsNoAuthMode(true)
setUser(null)
} else if (data.authenticated && data.user) {
setIsNoAuthMode(false)
setUser(data.user)
} else {
setIsNoAuthMode(false)
setUser(null)
}
setIsLoading(false)
} catch (error) {
console.error('Auth check failed:', error)
setUser(null)
} finally {
setIsLoading(false)
// Network error - backend not ready, keep loading and retry
console.log('Backend not ready, retrying in 2 seconds...')
setTimeout(checkAuth, 2000)
}
}
const login = () => {
// Don't allow login in no-auth mode
if (isNoAuthMode) {
console.log('Login attempted in no-auth mode - ignored')
return
}
// Use the correct auth callback URL, not connectors callback
const redirectUri = `${window.location.origin}/auth/callback`
@ -111,6 +135,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
const logout = async () => {
// Don't allow logout in no-auth mode
if (isNoAuthMode) {
console.log('Logout attempted in no-auth mode - ignored')
return
}
try {
await fetch('/api/auth/logout', {
method: 'POST',
@ -133,6 +163,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
user,
isLoading,
isAuthenticated: !!user,
isNoAuthMode,
login,
logout,
refreshAuth,

View file

@ -42,6 +42,8 @@ interface ChatContextType {
conversationDocs: ConversationDocument[]
addConversationDoc: (filename: string) => void
clearConversationDocs: () => void
placeholderConversation: ConversationData | null
setPlaceholderConversation: (conversation: ConversationData | null) => void
}
const ChatContext = createContext<ChatContextType | undefined>(undefined)
@ -60,6 +62,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
const [refreshTrigger, setRefreshTrigger] = 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)
@ -71,13 +74,32 @@ export function ChatProvider({ children }: ChatProviderProps) {
// 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()
}
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) => {
@ -97,6 +119,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
...prev,
[endpoint]: responseId
}))
// Clear placeholder when forking
setPlaceholderConversation(null)
// The messages are already set by the chat page component before calling this
}
@ -116,6 +140,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
conversationDocs,
addConversationDoc,
clearConversationDocs,
placeholderConversation,
setPlaceholderConversation,
}
return (

View file

@ -37,10 +37,10 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
const [isPolling, setIsPolling] = useState(false)
const [isFetching, setIsFetching] = useState(false)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const { isAuthenticated } = useAuth()
const { isAuthenticated, isNoAuthMode } = useAuth()
const fetchTasks = useCallback(async () => {
if (!isAuthenticated) return
if (!isAuthenticated && !isNoAuthMode) return
setIsFetching(true)
try {
@ -81,7 +81,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
} finally {
setIsFetching(false)
}
}, [isAuthenticated]) // Removed 'tasks' from dependencies to prevent infinite loop!
}, [isAuthenticated, isNoAuthMode]) // Removed 'tasks' from dependencies to prevent infinite loop!
const addTask = useCallback((taskId: string) => {
// Immediately start aggressive polling for the new task
@ -163,7 +163,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
// Periodic polling for task updates
useEffect(() => {
if (!isAuthenticated) return
if (!isAuthenticated && !isNoAuthMode) return
setIsPolling(true)
@ -177,7 +177,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
clearInterval(interval)
setIsPolling(false)
}
}, [isAuthenticated, fetchTasks])
}, [isAuthenticated, isNoAuthMode, fetchTasks])
const value: TaskContextType = {
tasks,