import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import ChatSidebar from './ChatSidebar' import ChatInterface from './ChatInterface' import StyledModal from '../StyledModal' import api from '~/lib/api' import { formatBytes } from '~/lib/util' import { useModals } from '~/context/ModalContext' import { ChatMessage } from '../../../types/chat' import classNames from '~/lib/classNames' import { IconX } from '@tabler/icons-react' import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama' import { useSystemSetting } from '~/hooks/useSystemSetting' interface ChatProps { enabled: boolean isInModal?: boolean onClose?: () => void suggestionsEnabled?: boolean streamingEnabled?: boolean } export default function Chat({ enabled, isInModal, onClose, suggestionsEnabled = false, streamingEnabled = true, }: ChatProps) { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() const [activeSessionId, setActiveSessionId] = useState(null) const [messages, setMessages] = useState([]) const [selectedModel, setSelectedModel] = useState('') const [isStreamingResponse, setIsStreamingResponse] = useState(false) const streamAbortRef = useRef(null) // Fetch all sessions const { data: sessions = [] } = useQuery({ queryKey: ['chatSessions'], queryFn: () => api.getChatSessions(), enabled, select: (data) => data?.map((s) => ({ id: s.id, title: s.title, model: s.model || undefined, timestamp: new Date(s.timestamp), lastMessage: s.lastMessage || undefined, })) || [], }) const activeSession = sessions.find((s) => s.id === activeSessionId) const { data: lastModelSetting } = useSystemSetting({ key: 'chat.lastModel', enabled }) const { data: installedModels = [], isLoading: isLoadingModels } = useQuery({ queryKey: ['installedModels'], queryFn: () => api.getInstalledModels(), enabled, select: (data) => data || [], }) const { data: chatSuggestions, isLoading: chatSuggestionsLoading } = useQuery({ queryKey: ['chatSuggestions'], queryFn: async ({ signal }) => { const res = await api.getChatSuggestions(signal) return res ?? [] }, enabled: suggestionsEnabled && !activeSessionId, refetchOnWindowFocus: false, refetchOnMount: false, }) const rewriteModelAvailable = useMemo(() => { return installedModels.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL) }, [installedModels]) const deleteAllSessionsMutation = useMutation({ mutationFn: () => api.deleteAllChatSessions(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['chatSessions'] }) setActiveSessionId(null) setMessages([]) closeAllModals() }, }) const chatMutation = useMutation({ mutationFn: (request: { model: string messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> sessionId?: number }) => api.sendChatMessage({ ...request, stream: false }), onSuccess: async (data) => { if (!data || !activeSessionId) { throw new Error('No response from Ollama') } // Add assistant message const assistantMessage: ChatMessage = { id: `msg-${Date.now()}-assistant`, role: 'assistant', content: data.message?.content || 'Sorry, I could not generate a response.', timestamp: new Date(), } setMessages((prev) => [...prev, assistantMessage]) // Refresh sessions to pick up backend-persisted messages and title queryClient.invalidateQueries({ queryKey: ['chatSessions'] }) setTimeout(() => queryClient.invalidateQueries({ queryKey: ['chatSessions'] }), 3000) }, onError: (error) => { console.error('Error sending message:', error) const errorMessage: ChatMessage = { id: `msg-${Date.now()}-error`, role: 'assistant', content: 'Sorry, there was an error processing your request. Please try again.', timestamp: new Date(), } setMessages((prev) => [...prev, errorMessage]) }, }) // Set default model: prefer last used model, fall back to first installed if last model not available useEffect(() => { if (installedModels.length > 0 && !selectedModel) { const lastModel = lastModelSetting?.value as string | undefined if (lastModel && installedModels.some((m) => m.name === lastModel)) { setSelectedModel(lastModel) } else { setSelectedModel(installedModels[0].name) } } }, [installedModels, selectedModel, lastModelSetting]) // Persist model selection useEffect(() => { if (selectedModel) { api.updateSetting('chat.lastModel', selectedModel) } }, [selectedModel]) const handleNewChat = useCallback(() => { // Just clear the active session and messages - don't create a session yet setActiveSessionId(null) setMessages([]) }, []) const handleClearHistory = useCallback(() => { openModal( deleteAllSessionsMutation.mutate()} onCancel={closeAllModals} open={true} confirmText="Clear All" cancelText="Cancel" confirmVariant="danger" >

Are you sure you want to delete all chat sessions? This action cannot be undone and all conversations will be permanently deleted.

, 'confirm-clear-history-modal' ) }, [openModal, closeAllModals, deleteAllSessionsMutation]) const handleSessionSelect = useCallback( async (sessionId: string) => { // Cancel any ongoing suggestions fetch queryClient.cancelQueries({ queryKey: ['chatSuggestions'] }) setActiveSessionId(sessionId) // Load messages for this session const sessionData = await api.getChatSession(sessionId) if (sessionData?.messages) { setMessages( sessionData.messages.map((m) => ({ id: m.id, role: m.role, content: m.content, timestamp: new Date(m.timestamp), })) ) } else { setMessages([]) } // Set the model to match the session's model if it exists and is available if (sessionData?.model) { setSelectedModel(sessionData.model) } }, [installedModels, queryClient] ) const handleSendMessage = useCallback( async (content: string) => { let sessionId = activeSessionId // Create a new session if none exists if (!sessionId) { const newSession = await api.createChatSession('New Chat', selectedModel) if (newSession) { sessionId = newSession.id setActiveSessionId(sessionId) queryClient.invalidateQueries({ queryKey: ['chatSessions'] }) } else { return } } // Add user message to UI const userMessage: ChatMessage = { id: `msg-${Date.now()}`, role: 'user', content, timestamp: new Date(), } setMessages((prev) => [...prev, userMessage]) const chatMessages = [ ...messages.map((m) => ({ role: m.role, content: m.content })), { role: 'user' as const, content }, ] if (streamingEnabled !== false) { // Streaming path const abortController = new AbortController() streamAbortRef.current = abortController setIsStreamingResponse(true) const assistantMsgId = `msg-${Date.now()}-assistant` let isFirstChunk = true let fullContent = '' let thinkingContent = '' let isThinkingPhase = true let thinkingStartTime: number | null = null let thinkingDuration: number | null = null try { await api.streamChatMessage( { model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined }, (chunkContent, chunkThinking, done) => { if (chunkThinking.length > 0 && thinkingStartTime === null) { thinkingStartTime = Date.now() } if (isFirstChunk) { isFirstChunk = false setIsStreamingResponse(false) setMessages((prev) => [ ...prev, { id: assistantMsgId, role: 'assistant', content: chunkContent, thinking: chunkThinking, timestamp: new Date(), isStreaming: true, isThinking: chunkThinking.length > 0 && chunkContent.length === 0, thinkingDuration: undefined, }, ]) } else { if (isThinkingPhase && chunkContent.length > 0) { isThinkingPhase = false if (thinkingStartTime !== null) { thinkingDuration = Math.max(1, Math.round((Date.now() - thinkingStartTime) / 1000)) } } setMessages((prev) => prev.map((m) => m.id === assistantMsgId ? { ...m, content: m.content + chunkContent, thinking: (m.thinking ?? '') + chunkThinking, isStreaming: !done, isThinking: isThinkingPhase, thinkingDuration: thinkingDuration ?? undefined, } : m ) ) } fullContent += chunkContent thinkingContent += chunkThinking }, abortController.signal ) } catch (error: any) { if (error?.name !== 'AbortError') { setMessages((prev) => { const hasAssistantMsg = prev.some((m) => m.id === assistantMsgId) if (hasAssistantMsg) { return prev.map((m) => m.id === assistantMsgId ? { ...m, isStreaming: false } : m ) } return [ ...prev, { id: assistantMsgId, role: 'assistant', content: 'Sorry, there was an error processing your request. Please try again.', timestamp: new Date(), }, ] }) } } finally { setIsStreamingResponse(false) streamAbortRef.current = null } if (fullContent && sessionId) { // Ensure the streaming cursor is removed setMessages((prev) => prev.map((m) => m.id === assistantMsgId ? { ...m, isStreaming: false } : m ) ) // Refresh sessions to pick up backend-persisted messages and title queryClient.invalidateQueries({ queryKey: ['chatSessions'] }) setTimeout(() => queryClient.invalidateQueries({ queryKey: ['chatSessions'] }), 3000) } } else { // Non-streaming (legacy) path chatMutation.mutate({ model: selectedModel || 'llama3.2', messages: chatMessages, sessionId: sessionId ? Number(sessionId) : undefined, }) } }, [activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled] ) return (

{activeSession?.title || 'New Chat'}

{isLoadingModels ? (
Loading models...
) : installedModels.length === 0 ? (
No models installed
) : ( )}
{isInModal && ( )}
) }